neoagent 2.1.18-beta.21 → 2.1.18-beta.22

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.
@@ -0,0 +1,353 @@
1
+ const crypto = require('crypto');
2
+ const db = require('../../../db/database');
3
+ const {
4
+ ExtensionBrowserUnavailableError,
5
+ createCommandMessage,
6
+ parseExtensionMessage,
7
+ } = require('./protocol');
8
+
9
+ const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000;
10
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 1000;
11
+
12
+ function sha256(value) {
13
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
14
+ }
15
+
16
+ function randomSecret(bytes = 32) {
17
+ return crypto.randomBytes(bytes).toString('base64url');
18
+ }
19
+
20
+ function isoDate(offsetMs = 0) {
21
+ return new Date(Date.now() + offsetMs).toISOString();
22
+ }
23
+
24
+ function safeJson(value) {
25
+ try {
26
+ return JSON.stringify(value || {});
27
+ } catch {
28
+ return '{}';
29
+ }
30
+ }
31
+
32
+ function parseJson(value) {
33
+ try {
34
+ return JSON.parse(value || '{}') || {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ class BrowserExtensionRegistry {
41
+ constructor(options = {}) {
42
+ this.db = options.db || db;
43
+ this.commandTimeoutMs = Number(options.commandTimeoutMs || process.env.NEOAGENT_BROWSER_EXTENSION_COMMAND_TIMEOUT_MS || DEFAULT_COMMAND_TIMEOUT_MS);
44
+ this.pairingTtlMs = Number(options.pairingTtlMs || process.env.NEOAGENT_BROWSER_EXTENSION_PAIRING_TTL_MS || DEFAULT_PAIRING_TTL_MS);
45
+ this.connectionsByUser = new Map();
46
+ }
47
+
48
+ createPairingRequest(options = {}) {
49
+ const pairingId = crypto.randomUUID();
50
+ const pairingSecret = randomSecret(32);
51
+ const expiresAt = isoDate(this.pairingTtlMs);
52
+ this.db.prepare(
53
+ `INSERT INTO browser_extension_pairing_requests (
54
+ id, pairing_secret_hash, status, expires_at, metadata_json
55
+ ) VALUES (?, ?, 'pending', ?, ?)`
56
+ ).run(pairingId, sha256(pairingSecret), expiresAt, safeJson({
57
+ extensionName: options.extensionName || null,
58
+ userAgent: options.userAgent || null,
59
+ }));
60
+ return { pairingId, pairingSecret, expiresAt };
61
+ }
62
+
63
+ getPairingRequest(pairingId) {
64
+ return this.db.prepare(
65
+ `SELECT * FROM browser_extension_pairing_requests WHERE id = ?`
66
+ ).get(String(pairingId || '')) || null;
67
+ }
68
+
69
+ approvePairing(pairingId, userId) {
70
+ const row = this.getPairingRequest(pairingId);
71
+ if (!row) {
72
+ const error = new Error('Pairing request not found.');
73
+ error.status = 404;
74
+ throw error;
75
+ }
76
+ if (row.status !== 'pending') {
77
+ const error = new Error('Pairing request is no longer pending.');
78
+ error.status = 409;
79
+ throw error;
80
+ }
81
+ if (Date.parse(row.expires_at) <= Date.now()) {
82
+ this.db.prepare(
83
+ `UPDATE browser_extension_pairing_requests SET status = 'expired' WHERE id = ?`
84
+ ).run(row.id);
85
+ const error = new Error('Pairing request expired.');
86
+ error.status = 410;
87
+ throw error;
88
+ }
89
+ this.db.prepare(
90
+ `UPDATE browser_extension_pairing_requests
91
+ SET user_id = ?, status = 'approved', approved_at = datetime('now')
92
+ WHERE id = ?`
93
+ ).run(userId, row.id);
94
+ return { success: true, pairingId: row.id };
95
+ }
96
+
97
+ claimPairing(pairingId, pairingSecret, options = {}) {
98
+ const row = this.getPairingRequest(pairingId);
99
+ if (!row) {
100
+ const error = new Error('Pairing request not found.');
101
+ error.status = 404;
102
+ throw error;
103
+ }
104
+ if (row.status !== 'approved' || !row.user_id) {
105
+ const error = new Error('Pairing request is not approved.');
106
+ error.status = 409;
107
+ throw error;
108
+ }
109
+ if (Date.parse(row.expires_at) <= Date.now()) {
110
+ this.db.prepare(
111
+ `UPDATE browser_extension_pairing_requests SET status = 'expired' WHERE id = ?`
112
+ ).run(row.id);
113
+ const error = new Error('Pairing request expired.');
114
+ error.status = 410;
115
+ throw error;
116
+ }
117
+ if (sha256(pairingSecret) !== row.pairing_secret_hash) {
118
+ const error = new Error('Invalid pairing secret.');
119
+ error.status = 401;
120
+ throw error;
121
+ }
122
+
123
+ const tokenId = crypto.randomUUID();
124
+ const token = `nbe_${randomSecret(36)}`;
125
+ const extensionName = String(options.extensionName || 'Chrome Extension').slice(0, 120);
126
+ const metadata = {
127
+ extensionName,
128
+ userAgent: options.userAgent || null,
129
+ };
130
+
131
+ const tx = this.db.transaction(() => {
132
+ this.db.prepare(
133
+ `UPDATE browser_extension_pairing_requests
134
+ SET status = 'claimed', claimed_at = datetime('now')
135
+ WHERE id = ?`
136
+ ).run(row.id);
137
+ this.db.prepare(
138
+ `INSERT INTO browser_extension_tokens (
139
+ id, user_id, token_hash, name, status, metadata_json
140
+ ) VALUES (?, ?, ?, ?, 'active', ?)`
141
+ ).run(tokenId, row.user_id, sha256(token), extensionName, safeJson(metadata));
142
+ });
143
+ tx();
144
+
145
+ return {
146
+ token,
147
+ tokenId,
148
+ userId: row.user_id,
149
+ extensionName,
150
+ };
151
+ }
152
+
153
+ validateToken(token) {
154
+ const row = this.db.prepare(
155
+ `SELECT * FROM browser_extension_tokens
156
+ WHERE token_hash = ? AND status = 'active'`
157
+ ).get(sha256(token));
158
+ if (!row) return null;
159
+ return {
160
+ ...row,
161
+ metadata: parseJson(row.metadata_json),
162
+ };
163
+ }
164
+
165
+ registerConnection(tokenRow, ws, meta = {}) {
166
+ const userId = String(tokenRow.user_id);
167
+ const existing = this.connectionsByUser.get(userId);
168
+ if (existing && existing.ws !== ws) {
169
+ existing.close('replaced by a newer extension connection');
170
+ }
171
+
172
+ const connection = new ExtensionBrowserConnection({
173
+ registry: this,
174
+ ws,
175
+ userId,
176
+ tokenId: tokenRow.id,
177
+ meta: { ...tokenRow.metadata, ...meta },
178
+ timeoutMs: this.commandTimeoutMs,
179
+ });
180
+ this.connectionsByUser.set(userId, connection);
181
+ this.db.prepare(
182
+ `UPDATE browser_extension_tokens
183
+ SET last_connected_at = datetime('now'), last_seen_at = datetime('now')
184
+ WHERE id = ?`
185
+ ).run(tokenRow.id);
186
+ return connection;
187
+ }
188
+
189
+ unregisterConnection(connection) {
190
+ const userId = String(connection.userId);
191
+ if (this.connectionsByUser.get(userId) === connection) {
192
+ this.connectionsByUser.delete(userId);
193
+ }
194
+ }
195
+
196
+ getConnection(userId) {
197
+ return this.connectionsByUser.get(String(userId));
198
+ }
199
+
200
+ isConnected(userId) {
201
+ return Boolean(this.getConnection(userId)?.isOpen());
202
+ }
203
+
204
+ async dispatch(userId, command, payload = {}, options = {}) {
205
+ const connection = this.getConnection(userId);
206
+ if (!connection || !connection.isOpen()) {
207
+ throw new ExtensionBrowserUnavailableError();
208
+ }
209
+ const result = await connection.sendCommand(command, payload, options);
210
+ this.db.prepare(
211
+ `UPDATE browser_extension_tokens SET last_seen_at = datetime('now') WHERE id = ?`
212
+ ).run(connection.tokenId);
213
+ return result;
214
+ }
215
+
216
+ getStatus(userId) {
217
+ const connected = this.getConnection(userId);
218
+ const tokens = this.db.prepare(
219
+ `SELECT id, name, status, last_connected_at, last_seen_at, revoked_at, created_at, metadata_json
220
+ FROM browser_extension_tokens
221
+ WHERE user_id = ?
222
+ ORDER BY created_at DESC`
223
+ ).all(userId).map((row) => ({
224
+ ...row,
225
+ metadata: parseJson(row.metadata_json),
226
+ metadata_json: undefined,
227
+ }));
228
+ return {
229
+ connected: Boolean(connected?.isOpen()),
230
+ activeTokenId: connected?.tokenId || null,
231
+ tokens,
232
+ connectedMeta: connected?.meta || null,
233
+ };
234
+ }
235
+
236
+ revoke(userId, tokenId = null) {
237
+ const targetTokenId = tokenId ? String(tokenId) : null;
238
+ if (targetTokenId) {
239
+ this.db.prepare(
240
+ `UPDATE browser_extension_tokens
241
+ SET status = 'revoked', revoked_at = datetime('now')
242
+ WHERE user_id = ? AND id = ?`
243
+ ).run(userId, targetTokenId);
244
+ } else {
245
+ this.db.prepare(
246
+ `UPDATE browser_extension_tokens
247
+ SET status = 'revoked', revoked_at = datetime('now')
248
+ WHERE user_id = ? AND status = 'active'`
249
+ ).run(userId);
250
+ }
251
+
252
+ const connection = this.getConnection(userId);
253
+ if (connection && (!targetTokenId || connection.tokenId === targetTokenId)) {
254
+ connection.close('extension token revoked');
255
+ }
256
+ return { success: true };
257
+ }
258
+
259
+ closeAll() {
260
+ for (const connection of this.connectionsByUser.values()) {
261
+ connection.close('server shutdown');
262
+ }
263
+ this.connectionsByUser.clear();
264
+ }
265
+ }
266
+
267
+ class ExtensionBrowserConnection {
268
+ constructor({ registry, ws, userId, tokenId, meta, timeoutMs }) {
269
+ this.registry = registry;
270
+ this.ws = ws;
271
+ this.userId = userId;
272
+ this.tokenId = tokenId;
273
+ this.meta = meta || {};
274
+ this.timeoutMs = timeoutMs;
275
+ this.pending = new Map();
276
+
277
+ ws.on('message', (data) => this.#handleMessage(data));
278
+ ws.on('close', () => this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.')));
279
+ ws.on('error', (error) => this.#closePending(error));
280
+ }
281
+
282
+ isOpen() {
283
+ return this.ws && this.ws.readyState === 1;
284
+ }
285
+
286
+ close(reason) {
287
+ try {
288
+ if (this.isOpen()) {
289
+ this.ws.close(1000, String(reason || 'closing').slice(0, 120));
290
+ }
291
+ } catch {}
292
+ this.registry.unregisterConnection(this);
293
+ this.#closePending(new ExtensionBrowserUnavailableError('Extension browser disconnected.'));
294
+ }
295
+
296
+ sendCommand(command, payload = {}, options = {}) {
297
+ if (!this.isOpen()) {
298
+ return Promise.reject(new ExtensionBrowserUnavailableError());
299
+ }
300
+ const id = crypto.randomUUID();
301
+ const timeoutMs = Number(options.timeoutMs || this.timeoutMs);
302
+ const message = createCommandMessage(id, command, payload);
303
+ return new Promise((resolve, reject) => {
304
+ const timer = setTimeout(() => {
305
+ this.pending.delete(id);
306
+ reject(new Error(`Browser extension command timed out: ${command}`));
307
+ }, timeoutMs);
308
+ this.pending.set(id, { resolve, reject, timer, command });
309
+ try {
310
+ this.ws.send(JSON.stringify(message));
311
+ } catch (error) {
312
+ clearTimeout(timer);
313
+ this.pending.delete(id);
314
+ reject(error);
315
+ }
316
+ });
317
+ }
318
+
319
+ #handleMessage(data) {
320
+ let message;
321
+ try {
322
+ message = parseExtensionMessage(data);
323
+ } catch {
324
+ return;
325
+ }
326
+ if (!message || message.type !== 'result' || !message.id) {
327
+ return;
328
+ }
329
+ const pending = this.pending.get(message.id);
330
+ if (!pending) return;
331
+ clearTimeout(pending.timer);
332
+ this.pending.delete(message.id);
333
+ if (message.ok === false) {
334
+ pending.reject(new Error(message.error || `Browser extension command failed: ${pending.command}`));
335
+ return;
336
+ }
337
+ pending.resolve(message.result || {});
338
+ }
339
+
340
+ #closePending(error) {
341
+ this.registry.unregisterConnection(this);
342
+ for (const pending of this.pending.values()) {
343
+ clearTimeout(pending.timer);
344
+ pending.reject(error);
345
+ }
346
+ this.pending.clear();
347
+ }
348
+ }
349
+
350
+ module.exports = {
351
+ BrowserExtensionRegistry,
352
+ sha256,
353
+ };
@@ -0,0 +1,133 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const CRC_TABLE = (() => {
5
+ const table = new Uint32Array(256);
6
+ for (let i = 0; i < 256; i += 1) {
7
+ let c = i;
8
+ for (let j = 0; j < 8; j += 1) {
9
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
10
+ }
11
+ table[i] = c >>> 0;
12
+ }
13
+ return table;
14
+ })();
15
+
16
+ function crc32(buffer) {
17
+ let crc = 0xffffffff;
18
+ for (const byte of buffer) {
19
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
20
+ }
21
+ return (crc ^ 0xffffffff) >>> 0;
22
+ }
23
+
24
+ function dosDateTime(date = new Date()) {
25
+ const year = Math.max(1980, date.getFullYear());
26
+ const dosTime =
27
+ (date.getHours() << 11) |
28
+ (date.getMinutes() << 5) |
29
+ Math.floor(date.getSeconds() / 2);
30
+ const dosDate =
31
+ ((year - 1980) << 9) |
32
+ ((date.getMonth() + 1) << 5) |
33
+ date.getDate();
34
+ return { dosTime, dosDate };
35
+ }
36
+
37
+ function listFiles(rootDir, prefix = '') {
38
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
39
+ const files = [];
40
+ for (const entry of entries) {
41
+ if (entry.name.startsWith('.')) continue;
42
+ const fullPath = path.join(rootDir, entry.name);
43
+ const archivePath = path.posix.join(prefix, entry.name);
44
+ if (entry.isDirectory()) {
45
+ files.push(...listFiles(fullPath, archivePath));
46
+ } else if (entry.isFile()) {
47
+ files.push({ fullPath, archivePath });
48
+ }
49
+ }
50
+ return files;
51
+ }
52
+
53
+ function u16(value) {
54
+ const buffer = Buffer.alloc(2);
55
+ buffer.writeUInt16LE(value);
56
+ return buffer;
57
+ }
58
+
59
+ function u32(value) {
60
+ const buffer = Buffer.alloc(4);
61
+ buffer.writeUInt32LE(value >>> 0);
62
+ return buffer;
63
+ }
64
+
65
+ function createZipFromDirectory(rootDir) {
66
+ const localParts = [];
67
+ const centralParts = [];
68
+ let offset = 0;
69
+
70
+ for (const file of listFiles(rootDir)) {
71
+ const content = fs.readFileSync(file.fullPath);
72
+ const name = Buffer.from(file.archivePath.replace(/\\/g, '/'), 'utf8');
73
+ const stat = fs.statSync(file.fullPath);
74
+ const { dosTime, dosDate } = dosDateTime(stat.mtime);
75
+ const crc = crc32(content);
76
+
77
+ const localHeader = Buffer.concat([
78
+ u32(0x04034b50),
79
+ u16(20),
80
+ u16(0),
81
+ u16(0),
82
+ u16(dosTime),
83
+ u16(dosDate),
84
+ u32(crc),
85
+ u32(content.length),
86
+ u32(content.length),
87
+ u16(name.length),
88
+ u16(0),
89
+ name,
90
+ ]);
91
+ localParts.push(localHeader, content);
92
+
93
+ const centralHeader = Buffer.concat([
94
+ u32(0x02014b50),
95
+ u16(20),
96
+ u16(20),
97
+ u16(0),
98
+ u16(0),
99
+ u16(dosTime),
100
+ u16(dosDate),
101
+ u32(crc),
102
+ u32(content.length),
103
+ u32(content.length),
104
+ u16(name.length),
105
+ u16(0),
106
+ u16(0),
107
+ u16(0),
108
+ u16(0),
109
+ u32(0),
110
+ u32(offset),
111
+ name,
112
+ ]);
113
+ centralParts.push(centralHeader);
114
+ offset += localHeader.length + content.length;
115
+ }
116
+
117
+ const centralDirectory = Buffer.concat(centralParts);
118
+ const end = Buffer.concat([
119
+ u32(0x06054b50),
120
+ u16(0),
121
+ u16(0),
122
+ u16(centralParts.length),
123
+ u16(centralParts.length),
124
+ u32(centralDirectory.length),
125
+ u32(offset),
126
+ u16(0),
127
+ ]);
128
+ return Buffer.concat([...localParts, centralDirectory, end]);
129
+ }
130
+
131
+ module.exports = {
132
+ createZipFromDirectory,
133
+ };
@@ -19,6 +19,7 @@ const { CLIExecutor } = require('./cli/executor');
19
19
  const { IntegrationManager } = require('./integrations/manager');
20
20
  const { ArtifactStore } = require('./artifacts/store');
21
21
  const { RuntimeManager } = require('./runtime/manager');
22
+ const { BrowserExtensionRegistry } = require('./browser/extension/registry');
22
23
  const { assertRuntimeValidation, getRuntimeValidation } = require('./runtime/validation');
23
24
  const {
24
25
  getErrorMessage,
@@ -47,6 +48,12 @@ function createArtifactStore(app) {
47
48
  return artifactStore;
48
49
  }
49
50
 
51
+ function createBrowserExtensionRegistry(app) {
52
+ const registry = registerLocal(app, 'browserExtensionRegistry', new BrowserExtensionRegistry());
53
+ logServiceReady('Browser extension registry ready');
54
+ return registry;
55
+ }
56
+
50
57
  function createMemoryManager(app) {
51
58
  const memoryManager = registerLocal(app, 'memoryManager', new MemoryManager());
52
59
  logServiceReady('Memory manager ready');
@@ -260,6 +267,7 @@ function createRuntimeManager(app, cliExecutor) {
260
267
  new RuntimeManager({
261
268
  cliExecutor,
262
269
  artifactStore: app.locals.artifactStore,
270
+ browserExtensionRegistry: app.locals.browserExtensionRegistry,
263
271
  getHostBrowserProvider: (userId) => {
264
272
  const resolver = app.locals.getBrowserControllerForUser;
265
273
  if (typeof resolver === 'function') {
@@ -428,6 +436,7 @@ async function startServices(app, io) {
428
436
  try {
429
437
  const cliExecutor = createCliExecutor(app);
430
438
  const artifactStore = createArtifactStore(app);
439
+ createBrowserExtensionRegistry(app);
431
440
  const memoryManager = createMemoryManager(app);
432
441
  const mcpClient = createMcpClient(app);
433
442
  const integrationManager = createIntegrationManager(app);
@@ -560,6 +569,15 @@ async function stopServices(app) {
560
569
  );
561
570
  }
562
571
 
572
+ if (app.locals.browserExtensionRegistry) {
573
+ try {
574
+ app.locals.browserExtensionRegistry.closeAll();
575
+ logServiceReady('Browser extension connections closed');
576
+ } catch (err) {
577
+ console.error('[BrowserExtension] Shutdown error:', getErrorMessage(err));
578
+ }
579
+ }
580
+
563
581
  if (app.locals.cliExecutor) {
564
582
  try {
565
583
  app.locals.cliExecutor.killAll('shutdown');
@@ -3,36 +3,7 @@ const { LocalVmExecutionBackend } = require('./backends/local-vm');
3
3
  const { RemoteWorkerExecutionBackend } = require('./backends/remote');
4
4
  const { QemuVmManager } = require('./qemu');
5
5
  const { getRuntimeSettings } = require('./settings');
6
-
7
- class UnsupportedExtensionBrowserProvider {
8
- constructor() {
9
- this.headless = false;
10
- }
11
-
12
- #unsupported() {
13
- return { error: 'Browser extension backend is planned but not implemented yet.' };
14
- }
15
-
16
- navigate() { return this.#unsupported(); }
17
- click() { return this.#unsupported(); }
18
- clickPoint() { return this.#unsupported(); }
19
- type() { return this.#unsupported(); }
20
- typeText() { return this.#unsupported(); }
21
- pressKey() { return this.#unsupported(); }
22
- scroll() { return this.#unsupported(); }
23
- extract() { return this.#unsupported(); }
24
- evaluate() { return this.#unsupported(); }
25
- screenshot() { return this.#unsupported(); }
26
- launch() { return this.#unsupported(); }
27
- closeBrowser() { return Promise.resolve({ success: true }); }
28
- fill() { return this.#unsupported(); }
29
- extractContent() { return this.#unsupported(); }
30
- executeJS() { return this.#unsupported(); }
31
- getPageInfo() { return Promise.resolve({ url: null, title: null, unsupported: true }); }
32
- isLaunched() { return false; }
33
- getPageCount() { return 0; }
34
- setHeadless() { return Promise.resolve({ success: false, unsupported: true }); }
35
- }
6
+ const { ExtensionBrowserProvider } = require('../browser/extension/provider');
36
7
 
37
8
  class RuntimeManager {
38
9
  constructor(options = {}) {
@@ -50,6 +21,11 @@ class RuntimeManager {
50
21
  getToken: (userId) => getRuntimeSettings(userId).remote_worker_token,
51
22
  artifactStore: options.artifactStore,
52
23
  });
24
+ this.getExtensionBrowserProvider = options.getExtensionBrowserProvider || ((userId) => new ExtensionBrowserProvider({
25
+ registry: options.browserExtensionRegistry,
26
+ artifactStore: options.artifactStore,
27
+ userId,
28
+ }));
53
29
  }
54
30
 
55
31
  getSettings(userId) {
@@ -72,7 +48,7 @@ class RuntimeManager {
72
48
  async getBrowserProviderForUser(userId) {
73
49
  const settings = this.getSettings(userId);
74
50
  if (settings.browser_backend === 'extension') {
75
- return new UnsupportedExtensionBrowserProvider();
51
+ return this.getExtensionBrowserProvider(userId);
76
52
  }
77
53
  const backend = this.resolveBackend(userId, settings.browser_backend === 'host' ? 'host' : settings.browser_backend);
78
54
  return backend.getBrowserProviderForUser(userId);
@@ -146,10 +146,6 @@ function validateRuntimeSettings(raw = {}) {
146
146
  }
147
147
  }
148
148
 
149
- if (settings.browser_backend === 'extension') {
150
- issues.push('The browser extension backend is planned but not available in this build.');
151
- }
152
-
153
149
  if (needsRemoteWorker && !settings.remote_worker_base_url) {
154
150
  issues.push('A remote worker URL is required when any runtime backend uses remote execution.');
155
151
  } else if (settings.remote_worker_base_url && !isValidRemoteWorkerBaseUrl(settings.remote_worker_base_url)) {