@livedesk/hub 0.1.3 → 0.1.5
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 +15 -15
- package/package.json +2 -1
- package/src/agents/agent-audit-store.js +93 -0
- package/src/agents/agent-device-scope.js +29 -0
- package/src/agents/agent-manager.js +175 -0
- package/src/agents/agent-permission-store.js +78 -0
- package/src/agents/agent-permissions.js +209 -0
- package/src/agents/agent-settings.js +195 -0
- package/src/agents/agent-tool-registry.js +322 -0
- package/src/agents/codex-agent-runtime.js +720 -0
- package/src/agents/codex-mcp-server.js +100 -0
- package/src/agents/opencode-go-provider.js +260 -0
- package/src/agents/provider-errors.js +12 -0
- package/src/agents/secret-store.js +165 -0
- package/src/captures/capture-store.js +252 -0
- package/src/http/hub-role-guard.js +8 -0
- package/src/live-desk-update.js +457 -0
- package/src/mode4-atlas.js +7 -2
- package/src/remote-hub.js +5648 -5059
- package/src/runtime/hub-runtime-status.js +10 -0
- package/src/runtime/hub-runtime.js +14 -0
- package/src/server.js +3706 -1632
- package/src/settings/effective-device-policy.js +16 -0
- package/src/settings/settings-schema.js +251 -0
- package/src/settings/settings-store.js +77 -0
- package/src/transport/udp-hub-transport.js +281 -0
- package/src/transport/udp-protocol.js +216 -0
- package/src/transport/udp-rendezvous.js +78 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const KINDS = new Set(['screenshot', 'recording', 'last-30s', 'timelapse']);
|
|
7
|
+
const MIME_EXTENSIONS = new Map([
|
|
8
|
+
['image/png', '.png'],
|
|
9
|
+
['video/webm', '.webm']
|
|
10
|
+
]);
|
|
11
|
+
const MAX_INDEX_RECORDS = 500;
|
|
12
|
+
|
|
13
|
+
function captureRoot(dataDir) {
|
|
14
|
+
return path.join(dataDir || path.join(os.homedir(), '.livedesk'), 'captures');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeName(value, fallback = 'capture') {
|
|
18
|
+
const normalized = String(value || '')
|
|
19
|
+
.replace(/[\0\r\n\\/]+/g, '-')
|
|
20
|
+
.replace(/[^a-zA-Z0-9._ -]/g, '')
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, '-')
|
|
23
|
+
.slice(0, 160);
|
|
24
|
+
return normalized || fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeMimeType(value, fallback) {
|
|
28
|
+
const mimeType = String(value || fallback || '').split(';')[0].trim().toLowerCase();
|
|
29
|
+
return mimeType || fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeTarget(value) {
|
|
33
|
+
const target = value && typeof value === 'object' ? value : {};
|
|
34
|
+
return {
|
|
35
|
+
type: String(target.type || 'wall').trim().slice(0, 32),
|
|
36
|
+
deviceIds: Array.isArray(target.deviceIds)
|
|
37
|
+
? target.deviceIds.map(item => String(item || '').trim()).filter(Boolean).slice(0, 500)
|
|
38
|
+
: [],
|
|
39
|
+
monitorIndex: Number.isInteger(Number(target.monitorIndex)) ? Number(target.monitorIndex) : undefined,
|
|
40
|
+
label: safeName(target.label, 'Wall')
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function publicRecord(record) {
|
|
45
|
+
const { filePath, ...publicValue } = record;
|
|
46
|
+
return publicValue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function atomicJson(filePath, value) {
|
|
50
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
51
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
52
|
+
await rename(tempPath, filePath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class CaptureStore {
|
|
56
|
+
constructor({ dataDir } = {}) {
|
|
57
|
+
this.root = captureRoot(dataDir);
|
|
58
|
+
this.indexPath = path.join(this.root, 'index.json');
|
|
59
|
+
this.sessions = new Map();
|
|
60
|
+
this.index = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async initialize() {
|
|
64
|
+
await Promise.all(['images', 'recordings', 'timelapses', '.tmp'].map(folder =>
|
|
65
|
+
mkdir(path.join(this.root, folder), { recursive: true })));
|
|
66
|
+
try {
|
|
67
|
+
this.index = JSON.parse(await readFile(this.indexPath, 'utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
this.index = { captures: [] };
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(this.index.captures)) this.index.captures = [];
|
|
72
|
+
await this._cleanupTempFiles();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async ready() {
|
|
76
|
+
if (!this.index) await this.initialize();
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async addBuffer(buffer, metadata = {}) {
|
|
81
|
+
await this.ready();
|
|
82
|
+
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
|
|
83
|
+
const kind = KINDS.has(metadata.kind) ? metadata.kind : 'screenshot';
|
|
84
|
+
const mimeType = normalizeMimeType(metadata.mimeType, kind === 'screenshot' ? 'image/png' : 'video/webm');
|
|
85
|
+
if (!MIME_EXTENSIONS.has(mimeType)) {
|
|
86
|
+
throw Object.assign(new Error('capture-mime-type-unsupported'), { status: 415 });
|
|
87
|
+
}
|
|
88
|
+
const extension = MIME_EXTENSIONS.get(mimeType) || (kind === 'screenshot' ? '.png' : '.webm');
|
|
89
|
+
const fileNameBase = safeName(metadata.fileName, `LiveDesk_${kind}`);
|
|
90
|
+
const fileName = fileNameBase.toLowerCase().endsWith(extension) ? fileNameBase : `${fileNameBase}${extension}`;
|
|
91
|
+
const id = randomUUID();
|
|
92
|
+
const folder = kind === 'screenshot' ? 'images' : kind === 'timelapse' ? 'timelapses' : 'recordings';
|
|
93
|
+
const filePath = path.join(this.root, folder, `${id}${extension}`);
|
|
94
|
+
await writeFile(filePath, bytes, { mode: 0o600 });
|
|
95
|
+
try {
|
|
96
|
+
const record = await this._record({
|
|
97
|
+
id,
|
|
98
|
+
kind,
|
|
99
|
+
fileName,
|
|
100
|
+
mimeType,
|
|
101
|
+
sizeBytes: bytes.length,
|
|
102
|
+
durationMs: Number(metadata.durationMs) > 0 ? Number(metadata.durationMs) : undefined,
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
target: normalizeTarget(metadata.target),
|
|
105
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
106
|
+
filePath
|
|
107
|
+
});
|
|
108
|
+
return publicRecord(record);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await rm(filePath, { force: true });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async createSession(metadata = {}) {
|
|
116
|
+
await this.ready();
|
|
117
|
+
const kind = metadata.kind === 'timelapse' ? 'timelapse' : 'recording';
|
|
118
|
+
const mimeType = normalizeMimeType(metadata.mimeType, 'video/webm');
|
|
119
|
+
if (mimeType !== 'video/webm') {
|
|
120
|
+
throw Object.assign(new Error('capture-mime-type-unsupported'), { status: 415 });
|
|
121
|
+
}
|
|
122
|
+
const sessionId = randomUUID();
|
|
123
|
+
const tempPath = path.join(this.root, '.tmp', `${sessionId}.webm.part`);
|
|
124
|
+
await writeFile(tempPath, Buffer.alloc(0), { mode: 0o600 });
|
|
125
|
+
const fileNameBase = safeName(metadata.fileName, `LiveDesk_${kind}`);
|
|
126
|
+
const session = {
|
|
127
|
+
sessionId,
|
|
128
|
+
kind,
|
|
129
|
+
fileName: fileNameBase.toLowerCase().endsWith('.webm') ? fileNameBase : `${fileNameBase}.webm`,
|
|
130
|
+
mimeType,
|
|
131
|
+
target: normalizeTarget(metadata.target),
|
|
132
|
+
tempPath,
|
|
133
|
+
nextSequence: 0,
|
|
134
|
+
received: new Set(),
|
|
135
|
+
sizeBytes: 0,
|
|
136
|
+
startedAt: Date.now()
|
|
137
|
+
};
|
|
138
|
+
this.sessions.set(sessionId, session);
|
|
139
|
+
return { sessionId, kind, fileName: session.fileName };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async appendSessionChunk(sessionId, sequence, buffer) {
|
|
143
|
+
await this.ready();
|
|
144
|
+
const session = this.sessions.get(String(sessionId || ''));
|
|
145
|
+
if (!session) throw Object.assign(new Error('capture-session-not-found'), { status: 404 });
|
|
146
|
+
if (!Number.isInteger(sequence) || sequence < 0) {
|
|
147
|
+
throw Object.assign(new Error('capture-sequence-invalid'), { status: 400 });
|
|
148
|
+
}
|
|
149
|
+
if (session.received.has(sequence)) return { duplicate: true, sequence, sizeBytes: session.sizeBytes };
|
|
150
|
+
if (sequence !== session.nextSequence) {
|
|
151
|
+
throw Object.assign(new Error(`capture-sequence-gap:${session.nextSequence}`), { status: 409 });
|
|
152
|
+
}
|
|
153
|
+
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
|
|
154
|
+
await appendFile(session.tempPath, bytes);
|
|
155
|
+
session.received.add(sequence);
|
|
156
|
+
session.nextSequence += 1;
|
|
157
|
+
session.sizeBytes += bytes.length;
|
|
158
|
+
return { duplicate: false, sequence, sizeBytes: session.sizeBytes };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async finalizeSession(sessionId, durationMs) {
|
|
162
|
+
await this.ready();
|
|
163
|
+
const session = this.sessions.get(String(sessionId || ''));
|
|
164
|
+
if (!session) throw Object.assign(new Error('capture-session-not-found'), { status: 404 });
|
|
165
|
+
if (session.nextSequence === 0) throw Object.assign(new Error('capture-session-empty'), { status: 400 });
|
|
166
|
+
const extension = '.webm';
|
|
167
|
+
const folder = session.kind === 'timelapse' ? 'timelapses' : 'recordings';
|
|
168
|
+
const finalPath = path.join(this.root, folder, `${session.sessionId}${extension}`);
|
|
169
|
+
await rename(session.tempPath, finalPath);
|
|
170
|
+
try {
|
|
171
|
+
const bytes = await readFile(finalPath);
|
|
172
|
+
const record = await this._record({
|
|
173
|
+
id: session.sessionId,
|
|
174
|
+
kind: session.kind,
|
|
175
|
+
fileName: session.fileName,
|
|
176
|
+
mimeType: session.mimeType,
|
|
177
|
+
sizeBytes: bytes.length,
|
|
178
|
+
durationMs: Number(durationMs) > 0 ? Number(durationMs) : undefined,
|
|
179
|
+
createdAt: new Date().toISOString(),
|
|
180
|
+
target: session.target,
|
|
181
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
182
|
+
filePath: finalPath
|
|
183
|
+
});
|
|
184
|
+
this.sessions.delete(session.sessionId);
|
|
185
|
+
return publicRecord(record);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await rm(finalPath, { force: true });
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async cancelSession(sessionId) {
|
|
193
|
+
const session = this.sessions.get(String(sessionId || ''));
|
|
194
|
+
if (session) {
|
|
195
|
+
this.sessions.delete(session.sessionId);
|
|
196
|
+
await rm(session.tempPath, { force: true }).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async list() {
|
|
201
|
+
await this.ready();
|
|
202
|
+
return this.index.captures.map(publicRecord);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async get(id) {
|
|
206
|
+
await this.ready();
|
|
207
|
+
return this.index.captures.find(item => item.id === String(id || '')) || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async remove(id) {
|
|
211
|
+
await this.ready();
|
|
212
|
+
const index = this.index.captures.findIndex(item => item.id === String(id || ''));
|
|
213
|
+
if (index < 0) return false;
|
|
214
|
+
const [record] = this.index.captures.splice(index, 1);
|
|
215
|
+
await rm(this.resolveFilePath(record), { force: true });
|
|
216
|
+
await atomicJson(this.indexPath, { captures: this.index.captures });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
resolveFilePath(record) {
|
|
221
|
+
if (record?.filePath) return record.filePath;
|
|
222
|
+
const folder = record?.kind === 'screenshot' ? 'images' : record?.kind === 'timelapse' ? 'timelapses' : 'recordings';
|
|
223
|
+
const extension = record?.mimeType === 'image/png' ? '.png' : '.webm';
|
|
224
|
+
return path.join(this.root, folder, `${safeName(record?.id, 'missing')}${extension}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async _record(record) {
|
|
228
|
+
const previous = this.index.captures;
|
|
229
|
+
this.index.captures = [record, ...previous.filter(item => item.id !== record.id)].slice(0, MAX_INDEX_RECORDS);
|
|
230
|
+
await atomicJson(this.indexPath, { captures: this.index.captures });
|
|
231
|
+
const retained = new Set(this.index.captures.map(item => item.id));
|
|
232
|
+
await Promise.all(previous.filter(item => !retained.has(item.id)).map(item => rm(this.resolveFilePath(item), { force: true })));
|
|
233
|
+
return record;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async _cleanupTempFiles() {
|
|
237
|
+
// In-memory sessions cannot be resumed after a Hub restart. Remove only
|
|
238
|
+
// orphaned partials so they never become visible as completed captures.
|
|
239
|
+
const tempDir = path.join(this.root, '.tmp');
|
|
240
|
+
const entries = await readFileNames(tempDir);
|
|
241
|
+
await Promise.all(entries.filter(name => name.endsWith('.part')).map(name =>
|
|
242
|
+
rm(path.join(tempDir, name), { force: true })));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readFileNames(directory) {
|
|
247
|
+
try {
|
|
248
|
+
return await readdir(directory);
|
|
249
|
+
} catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|