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.
- package/README.md +4 -3
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_chat.dart +39 -6
- package/flutter_app/lib/src/backend_client.dart +17 -0
- package/lib/manager.js +28 -66
- package/package.json +1 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +46425 -46216
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +5 -48
- package/server/services/account/erasure.js +263 -0
- package/server/services/android/controller.js +6 -2
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +182 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
package/server/routes/account.js
CHANGED
|
@@ -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;
|
package/server/routes/admin.js
CHANGED
|
@@ -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
|
|
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
|
|
573
|
-
|
|
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
|
-
|
|
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;
|