neoagent 3.0.1-beta.20 → 3.0.1-beta.21

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.
@@ -328,4 +328,57 @@ router.delete('/providers/:id', accountLimiter, (req, res) => {
328
328
  }
329
329
  });
330
330
 
331
+ // GDPR Art. 20 — data portability. Returns a structured export of the
332
+ // authenticated user's own data (credential columns redacted).
333
+ router.get('/export', accountLimiter, (req, res) => {
334
+ try {
335
+ const { exportUserData } = require('../services/account/erasure');
336
+ const payload = exportUserData(req.session.userId);
337
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
338
+ res.setHeader(
339
+ 'Content-Disposition',
340
+ `attachment; filename="neoagent-data-export-${req.session.userId}.json"`,
341
+ );
342
+ res.send(JSON.stringify(payload, null, 2));
343
+ } catch (err) {
344
+ sendRouteError(res, err);
345
+ }
346
+ });
347
+
348
+ // GDPR Art. 17 — right to erasure. The user permanently deletes their own
349
+ // account and all associated data. Requires typing the exact username as a
350
+ // destructive-action confirmation (works for password and SSO accounts alike).
351
+ router.post('/delete', accountLimiter, (req, res) => {
352
+ try {
353
+ const userId = req.session.userId;
354
+ const user = db
355
+ .prepare('SELECT username FROM users WHERE id = ?')
356
+ .get(userId);
357
+ if (!user) {
358
+ return res.status(404).json({ error: 'Account not found.' });
359
+ }
360
+ const confirm = String(req.body?.confirmUsername || '').trim();
361
+ if (confirm !== user.username) {
362
+ return res.status(400).json({
363
+ error: 'Confirmation does not match your username.',
364
+ });
365
+ }
366
+
367
+ const { eraseUserData } = require('../services/account/erasure');
368
+ const result = eraseUserData(userId, {
369
+ runtimeManager: req.app.locals.runtimeManager,
370
+ });
371
+
372
+ req.session.destroy(() => {
373
+ res.clearCookie('neoagent.sid');
374
+ res.json({ ok: true, tablesCleared: result.tablesCleared });
375
+ });
376
+ } catch (err) {
377
+ if (err.code === 'NOT_FOUND') {
378
+ return res.status(404).json({ error: 'Account not found.' });
379
+ }
380
+ sendRouteError(res, err);
381
+ }
382
+ });
383
+
331
384
  module.exports = router;
@@ -563,59 +563,16 @@ router.get('/api/users', requireAdminAuth, (req, res) => {
563
563
  });
564
564
 
565
565
  router.delete('/api/users/:id', requireAdminAuth, (req, res) => {
566
- const db = require('../db/database');
567
- const { DATA_DIR } = require('../../runtime/paths');
566
+ const { eraseUserData } = require('../services/account/erasure');
568
567
  const { id } = req.params;
569
568
  if (!id) return res.status(400).json({ error: 'Missing user id' });
570
569
  if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'Invalid user id' });
571
570
  try {
572
- const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
573
- if (!user) return res.status(404).json({ error: 'User not found' });
574
-
575
- // Collect artifact paths before deletion
576
- const artifacts = db.prepare('SELECT storage_path FROM artifacts WHERE user_id = ?').all(id);
577
-
578
- const erase = db.transaction((uid) => {
579
- db.prepare('DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)').run(uid);
580
- db.prepare('DELETE FROM conversations WHERE user_id = ?').run(uid);
581
- db.prepare('DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)').run(uid);
582
- db.prepare('DELETE FROM agent_runs WHERE user_id = ?').run(uid);
583
- db.prepare('DELETE FROM messages WHERE user_id = ?').run(uid);
584
- db.prepare('DELETE FROM memories WHERE user_id = ?').run(uid);
585
- db.prepare('DELETE FROM integration_connections WHERE user_id = ?').run(uid);
586
- db.prepare('DELETE FROM platform_connections WHERE user_id = ?').run(uid);
587
- db.prepare('DELETE FROM mcp_servers WHERE user_id = ?').run(uid);
588
- db.prepare('DELETE FROM user_settings WHERE user_id = ?').run(uid);
589
- db.prepare('DELETE FROM user_sessions WHERE user_id = ?').run(uid);
590
- db.prepare('DELETE FROM desktop_companion_devices WHERE user_id = ?').run(uid);
591
- db.prepare('DELETE FROM scheduled_tasks WHERE user_id = ?').run(uid);
592
- db.prepare('DELETE FROM recording_sessions WHERE user_id = ?').run(uid);
593
- db.prepare('DELETE FROM screen_history WHERE user_id = ?').run(uid);
594
- db.prepare('DELETE FROM agents WHERE user_id = ?').run(uid);
595
- db.prepare('DELETE FROM artifacts WHERE user_id = ?').run(uid);
596
- db.prepare('DELETE FROM users WHERE id = ?').run(uid);
597
- });
598
- erase(id);
599
-
600
- // Clean up artifact files on disk — containment-checked
601
- const artifactsRoot = path.resolve(path.join(DATA_DIR, 'artifacts'));
602
- for (const artifact of artifacts) {
603
- try {
604
- const abs = path.resolve(path.isAbsolute(artifact.storage_path)
605
- ? artifact.storage_path
606
- : path.join(DATA_DIR, artifact.storage_path));
607
- if (abs.startsWith(artifactsRoot + path.sep)) {
608
- fs.rmSync(abs, { force: true });
609
- }
610
- } catch {}
611
- }
612
- const userArtifactDir = path.resolve(path.join(DATA_DIR, 'artifacts', id));
613
- if (userArtifactDir.startsWith(artifactsRoot + path.sep)) {
614
- try { fs.rmSync(userArtifactDir, { recursive: true, force: true }); } catch {}
615
- }
616
-
617
- res.json({ ok: true });
571
+ const result = eraseUserData(id, { runtimeManager: req.app.locals.runtimeManager });
572
+ res.json(result);
618
573
  } catch (err) {
574
+ if (err.code === 'NOT_FOUND') return res.status(404).json({ error: 'User not found' });
575
+ if (err.code === 'INVALID_ID') return res.status(400).json({ error: 'Invalid user id' });
619
576
  res.status(500).json({ error: String(err.message || err) });
620
577
  }
621
578
  });
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ // Complete, schema-driven erasure and export of a single user's data.
4
+ //
5
+ // Both the admin "GDPR: delete all user data" action and the self-service
6
+ // account-deletion endpoint go through eraseUserData() so the two can never
7
+ // drift apart. Rather than hand-maintain a list of tables (which previously
8
+ // missed billing, 2FA, embeddings and other user-scoped tables), we discover
9
+ // every table that has a `user_id` column at runtime via PRAGMA table_info and
10
+ // clear all of them. Child tables that are keyed by a parent id instead of
11
+ // user_id are cleared explicitly below.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const db = require('../../db/database');
16
+ const sessionsDb = require('../../db/sessions_db');
17
+ const { DATA_DIR, AGENT_DATA_DIR } = require('../../../runtime/paths');
18
+ const { sanitizeWorkspaceKey } = require('../workspace/manager');
19
+
20
+ // Tables that carry a `user_id` column but are global/shared configuration and
21
+ // must never be deleted as part of erasing one user.
22
+ const GLOBAL_TABLES = new Set(['billing_plans']);
23
+
24
+ // Column names whose values are credentials/secrets and must be redacted from a
25
+ // portability export (the user already controls these; echoing them back adds
26
+ // risk without adding portability value).
27
+ const SENSITIVE_COLUMN = /pass|secret|token|hash|cipher|encrypt|_iv$|nonce|totp|recovery|priv(ate)?_?key|api_?key|credential/i;
28
+
29
+ function userScopedTables() {
30
+ const rows = db
31
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
32
+ .all();
33
+ const tables = [];
34
+ for (const { name } of rows) {
35
+ if (name === 'users' || GLOBAL_TABLES.has(name) || name.startsWith('admin_')) {
36
+ continue;
37
+ }
38
+ if (name.startsWith('sqlite_')) {
39
+ continue;
40
+ }
41
+ let columns;
42
+ try {
43
+ columns = db.prepare(`PRAGMA table_info(${name})`).all();
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (columns.some((column) => column.name === 'user_id')) {
48
+ tables.push(name);
49
+ }
50
+ }
51
+ return tables;
52
+ }
53
+
54
+ // Child tables keyed by a parent id rather than user_id. Foreign keys are
55
+ // declared ON DELETE CASCADE, so with PRAGMA foreign_keys = ON these are removed
56
+ // automatically when the parent row goes — but we delete them explicitly first
57
+ // so erasure is correct even if foreign keys are ever disabled.
58
+ function deleteChildRows(uid) {
59
+ const childDeletes = [
60
+ 'DELETE FROM conversation_messages WHERE conversation_id IN (SELECT id FROM conversations WHERE user_id = ?)',
61
+ 'DELETE FROM agent_steps WHERE run_id IN (SELECT id FROM agent_runs WHERE user_id = ?)',
62
+ 'DELETE FROM ai_widget_snapshots WHERE widget_id IN (SELECT id FROM ai_widgets WHERE user_id = ?)',
63
+ 'DELETE FROM recording_transcript_segments WHERE session_id IN (SELECT id FROM recording_sessions WHERE user_id = ?)',
64
+ 'DELETE FROM recording_chunks WHERE source_id IN (SELECT s.id FROM recording_sources s JOIN recording_sessions rs ON s.session_id = rs.id WHERE rs.user_id = ?)',
65
+ 'DELETE FROM recording_sources WHERE session_id IN (SELECT id FROM recording_sessions WHERE user_id = ?)',
66
+ 'DELETE FROM memory_source_links WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)',
67
+ ];
68
+ for (const sql of childDeletes) {
69
+ try {
70
+ db.prepare(sql).run(uid);
71
+ } catch {
72
+ // Table may not exist on older schema versions — ignore.
73
+ }
74
+ }
75
+ }
76
+
77
+ function collectArtifactPaths(uid) {
78
+ try {
79
+ return db
80
+ .prepare('SELECT storage_path FROM artifacts WHERE user_id = ?')
81
+ .all(uid)
82
+ .map((row) => row.storage_path)
83
+ .filter(Boolean);
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function removeArtifactFiles(uid, storagePaths) {
90
+ const artifactsRoot = path.resolve(path.join(DATA_DIR, 'artifacts'));
91
+ for (const storagePath of storagePaths) {
92
+ try {
93
+ const abs = path.resolve(
94
+ path.isAbsolute(storagePath) ? storagePath : path.join(DATA_DIR, storagePath),
95
+ );
96
+ if (abs.startsWith(artifactsRoot + path.sep)) {
97
+ fs.rmSync(abs, { force: true });
98
+ }
99
+ } catch {
100
+ /* best effort */
101
+ }
102
+ }
103
+ const userArtifactDir = path.resolve(path.join(DATA_DIR, 'artifacts', String(uid)));
104
+ if (userArtifactDir.startsWith(artifactsRoot + path.sep)) {
105
+ try {
106
+ fs.rmSync(userArtifactDir, { recursive: true, force: true });
107
+ } catch {
108
+ /* best effort */
109
+ }
110
+ }
111
+ }
112
+
113
+ function removeWorkspaceDir(uid) {
114
+ const workspacesRoot = path.resolve(path.join(AGENT_DATA_DIR, 'workspaces'));
115
+ const workspaceDir = path.resolve(
116
+ path.join(workspacesRoot, sanitizeWorkspaceKey(uid)),
117
+ );
118
+ if (workspaceDir.startsWith(workspacesRoot + path.sep)) {
119
+ try {
120
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
121
+ } catch {
122
+ /* best effort */
123
+ }
124
+ }
125
+ }
126
+
127
+ function purgeSessionStore(uid) {
128
+ // better-sqlite3-session-store keeps a `sessions` table whose `sess` column is
129
+ // the JSON-serialised session. Remove any row belonging to this user so their
130
+ // login is invalidated everywhere immediately.
131
+ for (const needle of [`%"userId":${uid}%`, `%"userId":"${uid}"%`]) {
132
+ try {
133
+ sessionsDb.prepare('DELETE FROM sessions WHERE sess LIKE ?').run(needle);
134
+ } catch {
135
+ /* table/store may differ — best effort */
136
+ }
137
+ }
138
+ }
139
+
140
+ function killUserRuntime(uid, runtimeManager) {
141
+ try {
142
+ const vmManager = runtimeManager?.browserBackend?.vmManager;
143
+ if (vmManager && typeof vmManager.killVm === 'function') {
144
+ // Fire-and-forget: container teardown should not block the HTTP response.
145
+ Promise.resolve(vmManager.killVm(String(uid))).catch(() => {});
146
+ }
147
+ } catch {
148
+ /* best effort */
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Permanently erase all data belonging to a user (GDPR Art. 17).
154
+ *
155
+ * @param {number|string} userId
156
+ * @param {object} [opts]
157
+ * @param {object} [opts.runtimeManager] live RuntimeManager so the user's
158
+ * sandbox container can be torn down as part of erasure.
159
+ * @returns {{ ok: true, tablesCleared: number }}
160
+ */
161
+ function eraseUserData(userId, opts = {}) {
162
+ const uid = Number(userId);
163
+ if (!Number.isInteger(uid) || uid <= 0) {
164
+ const error = new Error('Invalid user id');
165
+ error.code = 'INVALID_ID';
166
+ throw error;
167
+ }
168
+ const exists = db.prepare('SELECT id FROM users WHERE id = ?').get(uid);
169
+ if (!exists) {
170
+ const error = new Error('User not found');
171
+ error.code = 'NOT_FOUND';
172
+ throw error;
173
+ }
174
+
175
+ const artifactPaths = collectArtifactPaths(uid);
176
+ const tables = userScopedTables();
177
+
178
+ const erase = db.transaction(() => {
179
+ deleteChildRows(uid);
180
+ for (const table of tables) {
181
+ // `table` comes from sqlite_master introspection, never from user input.
182
+ db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).run(uid);
183
+ }
184
+ db.prepare('DELETE FROM users WHERE id = ?').run(uid);
185
+ });
186
+ erase();
187
+
188
+ // Off-database state: files on disk, the running sandbox, and active sessions.
189
+ removeArtifactFiles(uid, artifactPaths);
190
+ removeWorkspaceDir(uid);
191
+ purgeSessionStore(uid);
192
+ killUserRuntime(uid, opts.runtimeManager);
193
+
194
+ return { ok: true, tablesCleared: tables.length };
195
+ }
196
+
197
+ function redactRow(row) {
198
+ const out = {};
199
+ for (const [key, value] of Object.entries(row)) {
200
+ out[key] = SENSITIVE_COLUMN.test(key) && value != null ? '[redacted]' : value;
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * Build a structured, machine-readable export of a user's personal data
207
+ * (GDPR Art. 20 data portability). Credential/secret columns are redacted.
208
+ *
209
+ * @param {number|string} userId
210
+ * @returns {object}
211
+ */
212
+ function exportUserData(userId) {
213
+ const uid = Number(userId);
214
+ if (!Number.isInteger(uid) || uid <= 0) {
215
+ const error = new Error('Invalid user id');
216
+ error.code = 'INVALID_ID';
217
+ throw error;
218
+ }
219
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(uid);
220
+ if (!user) {
221
+ const error = new Error('User not found');
222
+ error.code = 'NOT_FOUND';
223
+ throw error;
224
+ }
225
+
226
+ const data = { account: redactRow(user) };
227
+ for (const table of userScopedTables()) {
228
+ try {
229
+ const rows = db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(uid);
230
+ if (rows.length > 0) {
231
+ data[table] = rows.map(redactRow);
232
+ }
233
+ } catch {
234
+ /* skip unreadable table */
235
+ }
236
+ }
237
+ // Conversation messages are keyed by conversation id, not user_id.
238
+ try {
239
+ const messages = db
240
+ .prepare(
241
+ 'SELECT m.* FROM conversation_messages m JOIN conversations c ON m.conversation_id = c.id WHERE c.user_id = ?',
242
+ )
243
+ .all(uid);
244
+ if (messages.length > 0) {
245
+ data.conversation_messages = messages.map(redactRow);
246
+ }
247
+ } catch {
248
+ /* best effort */
249
+ }
250
+
251
+ return {
252
+ exportedAt: new Date().toISOString(),
253
+ schema: 'neoagent.user-export.v1',
254
+ userId: uid,
255
+ data,
256
+ };
257
+ }
258
+
259
+ module.exports = {
260
+ eraseUserData,
261
+ exportUserData,
262
+ userScopedTables,
263
+ };
@@ -582,7 +582,8 @@ class AndroidController {
582
582
  });
583
583
 
584
584
  progress('Waiting for Android to boot (can take 2–5 min on first run)…');
585
- await this.#waitForBoot();
585
+ // Abort early if the emulator process dies, instead of polling until timeout.
586
+ await this.#waitForBoot({ isAlive: () => this.#isPidAlive(proc.pid) });
586
587
 
587
588
  writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
588
589
  console.log(`[Android] Emulator ready on ${this.adbSerial}`);
@@ -593,10 +594,13 @@ class AndroidController {
593
594
  });
594
595
  }
595
596
 
596
- async #waitForBoot(timeoutMs = 10 * 60 * 1000) {
597
+ async #waitForBoot({ timeoutMs = 10 * 60 * 1000, isAlive = () => true } = {}) {
597
598
  const adb = adbBin(this.sdkDir);
598
599
  const deadline = Date.now() + timeoutMs;
599
600
  while (Date.now() < deadline) {
601
+ if (!isAlive()) {
602
+ throw new Error('Emulator process exited before Android finished booting (check virtualization/KVM support and the system image).');
603
+ }
600
604
  try {
601
605
  const r = spawnSync(adb, ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf8', timeout: 5000 });
602
606
  if (r.stdout?.trim() === '1') return;