chrome-ai-bridge 2.3.2 → 2.3.4
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/build/src/config.js +25 -0
- package/build/src/main.js +165 -26
- package/package.json +1 -1
- package/scripts/cli.mjs +13 -37
package/build/src/config.js
CHANGED
|
@@ -62,3 +62,28 @@ export function getSessionConfig() {
|
|
|
62
62
|
cleanupIntervalMinutes: raw.interval > 0 ? raw.interval : 5,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Get IPC overload protection settings from environment variables or defaults.
|
|
67
|
+
*/
|
|
68
|
+
export function getIpcGuardConfig() {
|
|
69
|
+
const raw = {
|
|
70
|
+
maxSessions: Number(process.env.CAI_IPC_MAX_SESSIONS),
|
|
71
|
+
maxQueue: Number(process.env.CAI_IPC_MAX_QUEUE),
|
|
72
|
+
queueWaitTimeoutMs: Number(process.env.CAI_IPC_QUEUE_WAIT_TIMEOUT_MS),
|
|
73
|
+
sessionIdleMs: Number(process.env.CAI_IPC_SESSION_IDLE_MS),
|
|
74
|
+
startupDelayJitterMs: Number(process.env.CAI_STARTUP_DELAY_JITTER_MS),
|
|
75
|
+
startupProcessThreshold: Number(process.env.CAI_STARTUP_PROCESS_THRESHOLD),
|
|
76
|
+
primaryIdleMs: Number(process.env.CAI_PRIMARY_IDLE_MS),
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
maxSessions: raw.maxSessions > 0 ? Math.floor(raw.maxSessions) : 6,
|
|
80
|
+
maxQueue: raw.maxQueue > 0 ? Math.floor(raw.maxQueue) : 64,
|
|
81
|
+
queueWaitTimeoutMs: raw.queueWaitTimeoutMs > 0 ? Math.floor(raw.queueWaitTimeoutMs) : 10_000,
|
|
82
|
+
sessionIdleMs: raw.sessionIdleMs > 0 ? Math.floor(raw.sessionIdleMs) : 300_000,
|
|
83
|
+
startupDelayJitterMs: raw.startupDelayJitterMs > 0 ? Math.floor(raw.startupDelayJitterMs) : 1_500,
|
|
84
|
+
startupProcessThreshold: raw.startupProcessThreshold > 0
|
|
85
|
+
? Math.floor(raw.startupProcessThreshold)
|
|
86
|
+
: 8,
|
|
87
|
+
primaryIdleMs: raw.primaryIdleMs > 0 ? Math.floor(raw.primaryIdleMs) : 300_000,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/build/src/main.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import assert from 'node:assert';
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
18
19
|
import { randomUUID } from 'node:crypto';
|
|
19
20
|
import http from 'node:http';
|
|
20
21
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -31,7 +32,7 @@ import { getFastContext } from './fast-cdp/fast-context.js';
|
|
|
31
32
|
import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
|
|
32
33
|
import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
|
|
33
34
|
import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
|
|
34
|
-
import { getSessionConfig, IPC_CONFIG } from './config.js';
|
|
35
|
+
import { getIpcGuardConfig, getSessionConfig, IPC_CONFIG } from './config.js';
|
|
35
36
|
import { releaseLock, tryAcquireLockSafe, checkExistingPrimary, updateLockPort } from './process-lock.js';
|
|
36
37
|
import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
|
|
37
38
|
function readPackageJson() {
|
|
@@ -64,8 +65,33 @@ const MAX_STARTUP_ATTEMPTS = 5;
|
|
|
64
65
|
const BASE_DELAY_MS = 300;
|
|
65
66
|
const HEALTH_CHECK_RETRIES = 3;
|
|
66
67
|
const HEALTH_CHECK_INTERVAL_MS = 500;
|
|
68
|
+
const ipcGuardConfig = getIpcGuardConfig();
|
|
67
69
|
const instanceId = randomUUID();
|
|
68
70
|
let becamePrimary = false;
|
|
71
|
+
function countLocalBridgeInstances() {
|
|
72
|
+
try {
|
|
73
|
+
const output = execFileSync('ps', ['-axo', 'command'], {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
76
|
+
});
|
|
77
|
+
const lines = output.split('\n').filter(Boolean);
|
|
78
|
+
return lines.filter(line => line.includes('chrome-ai-bridge') &&
|
|
79
|
+
(line.includes('build/src/main.js') || line.includes('scripts/cli.mjs'))).length;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function applyStartupJitterIfNeeded() {
|
|
86
|
+
const instanceCount = countLocalBridgeInstances();
|
|
87
|
+
if (instanceCount < ipcGuardConfig.startupProcessThreshold) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const delayMs = Math.floor(Math.random() * ipcGuardConfig.startupDelayJitterMs);
|
|
91
|
+
logger(`[main] High startup concurrency detected (${instanceCount} processes). Applying jitter=${delayMs}ms.`);
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
93
|
+
}
|
|
94
|
+
await applyStartupJitterIfNeeded();
|
|
69
95
|
for (let attempt = 0; attempt < MAX_STARTUP_ATTEMPTS; attempt++) {
|
|
70
96
|
// 1. Try to become Primary (non-throwing)
|
|
71
97
|
const lockAcquired = await tryAcquireLockSafe(IPC_CONFIG.port, instanceId);
|
|
@@ -112,6 +138,11 @@ if (!becamePrimary) {
|
|
|
112
138
|
process.exit(1);
|
|
113
139
|
}
|
|
114
140
|
// ─── Primary mode ───
|
|
141
|
+
// Idle auto-exit tracking for Primary process
|
|
142
|
+
let primaryLastActivityAt = Date.now();
|
|
143
|
+
const touchPrimaryActivity = () => {
|
|
144
|
+
primaryLastActivityAt = Date.now();
|
|
145
|
+
};
|
|
115
146
|
// Start session cleanup timer
|
|
116
147
|
const sessionConfig = getSessionConfig();
|
|
117
148
|
const cleanupTimer = setInterval(async () => {
|
|
@@ -146,6 +177,7 @@ function registerTool(tool) {
|
|
|
146
177
|
inputSchema: tool.schema,
|
|
147
178
|
annotations: tool.annotations,
|
|
148
179
|
}, async (params) => {
|
|
180
|
+
touchPrimaryActivity();
|
|
149
181
|
const guard = await toolMutex.acquire();
|
|
150
182
|
try {
|
|
151
183
|
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
|
|
@@ -225,6 +257,75 @@ logDisclaimers();
|
|
|
225
257
|
// ─── IPC HTTP server (for proxy clients) ───
|
|
226
258
|
{
|
|
227
259
|
const ipcTransports = {};
|
|
260
|
+
const ipcSessionLastActivity = new Map();
|
|
261
|
+
const initQueue = [];
|
|
262
|
+
let initializingCount = 0;
|
|
263
|
+
const getSessionLoad = () => Object.keys(ipcTransports).length + initializingCount;
|
|
264
|
+
const touchIpcSession = (sessionId) => {
|
|
265
|
+
ipcSessionLastActivity.set(sessionId, Date.now());
|
|
266
|
+
};
|
|
267
|
+
const cleanupIpcSession = (sessionId) => {
|
|
268
|
+
if (ipcTransports[sessionId]) {
|
|
269
|
+
delete ipcTransports[sessionId];
|
|
270
|
+
}
|
|
271
|
+
ipcSessionLastActivity.delete(sessionId);
|
|
272
|
+
drainInitQueue();
|
|
273
|
+
};
|
|
274
|
+
function sendJsonRpcError(res, code, message, id = null, statusCode = 400) {
|
|
275
|
+
res.writeHead(statusCode).end(JSON.stringify({
|
|
276
|
+
jsonrpc: '2.0',
|
|
277
|
+
error: { code, message },
|
|
278
|
+
id,
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
function drainInitQueue() {
|
|
282
|
+
while (initQueue.length > 0 && getSessionLoad() < ipcGuardConfig.maxSessions) {
|
|
283
|
+
const waiter = initQueue.shift();
|
|
284
|
+
if (!waiter)
|
|
285
|
+
break;
|
|
286
|
+
clearTimeout(waiter.timeout);
|
|
287
|
+
waiter.resolve();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function waitForInitCapacity() {
|
|
291
|
+
if (getSessionLoad() < ipcGuardConfig.maxSessions) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (initQueue.length >= ipcGuardConfig.maxQueue) {
|
|
295
|
+
throw new Error('SERVER_QUEUE_FULL');
|
|
296
|
+
}
|
|
297
|
+
await new Promise((resolve, reject) => {
|
|
298
|
+
const timeout = setTimeout(() => {
|
|
299
|
+
const index = initQueue.findIndex(item => item.resolve === resolve);
|
|
300
|
+
if (index >= 0) {
|
|
301
|
+
initQueue.splice(index, 1);
|
|
302
|
+
}
|
|
303
|
+
reject(new Error('SERVER_BUSY_TIMEOUT'));
|
|
304
|
+
}, ipcGuardConfig.queueWaitTimeoutMs);
|
|
305
|
+
timeout.unref();
|
|
306
|
+
initQueue.push({ resolve, reject, timeout });
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const idleCleanupTimer = setInterval(async () => {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const staleSessionIds = Array.from(ipcSessionLastActivity.entries())
|
|
312
|
+
.filter(([, lastActivity]) => now - lastActivity > ipcGuardConfig.sessionIdleMs)
|
|
313
|
+
.map(([sessionId]) => sessionId);
|
|
314
|
+
if (staleSessionIds.length === 0) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
logger(`[ipc] Closing ${staleSessionIds.length} idle session(s) older than ${ipcGuardConfig.sessionIdleMs}ms.`);
|
|
318
|
+
for (const staleSessionId of staleSessionIds) {
|
|
319
|
+
try {
|
|
320
|
+
await ipcTransports[staleSessionId]?.close();
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// Ignore transport close errors and continue cleanup.
|
|
324
|
+
}
|
|
325
|
+
cleanupIpcSession(staleSessionId);
|
|
326
|
+
}
|
|
327
|
+
}, Math.max(10_000, Math.min(60_000, Math.floor(ipcGuardConfig.sessionIdleMs / 2))));
|
|
328
|
+
idleCleanupTimer.unref();
|
|
228
329
|
const ipcServer = http.createServer(async (req, res) => {
|
|
229
330
|
if (!req.url || !req.method) {
|
|
230
331
|
res.writeHead(400).end();
|
|
@@ -233,7 +334,15 @@ logDisclaimers();
|
|
|
233
334
|
const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
|
|
234
335
|
// Health endpoint
|
|
235
336
|
if (url.pathname === IPC_CONFIG.healthPath) {
|
|
236
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
337
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
|
338
|
+
status: 'ok',
|
|
339
|
+
pid: process.pid,
|
|
340
|
+
version,
|
|
341
|
+
instanceId,
|
|
342
|
+
activeSessions: Object.keys(ipcTransports).length,
|
|
343
|
+
queuedInitializations: initQueue.length,
|
|
344
|
+
sessionCapacity: ipcGuardConfig.maxSessions,
|
|
345
|
+
}));
|
|
237
346
|
return;
|
|
238
347
|
}
|
|
239
348
|
// MCP endpoint
|
|
@@ -261,40 +370,55 @@ logDisclaimers();
|
|
|
261
370
|
json = body ? JSON.parse(body) : null;
|
|
262
371
|
}
|
|
263
372
|
catch {
|
|
264
|
-
res
|
|
265
|
-
jsonrpc: '2.0',
|
|
266
|
-
error: { code: -32700, message: 'Parse error' },
|
|
267
|
-
id: null,
|
|
268
|
-
}));
|
|
373
|
+
sendJsonRpcError(res, -32700, 'Parse error');
|
|
269
374
|
return;
|
|
270
375
|
}
|
|
271
376
|
let ipcTransport;
|
|
377
|
+
touchPrimaryActivity();
|
|
272
378
|
if (sessionId && ipcTransports[sessionId]) {
|
|
273
379
|
ipcTransport = ipcTransports[sessionId];
|
|
380
|
+
touchIpcSession(sessionId);
|
|
274
381
|
}
|
|
275
382
|
else if (!sessionId && isInitializeRequest(json)) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (ipcTransport?.sessionId) {
|
|
284
|
-
delete ipcTransports[ipcTransport.sessionId];
|
|
383
|
+
try {
|
|
384
|
+
await waitForInitCapacity();
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const message = error instanceof Error ? error.message : 'SERVER_BUSY_TIMEOUT';
|
|
388
|
+
if (message === 'SERVER_QUEUE_FULL') {
|
|
389
|
+
sendJsonRpcError(res, -32002, message, null, 503);
|
|
285
390
|
}
|
|
286
|
-
|
|
287
|
-
|
|
391
|
+
else {
|
|
392
|
+
sendJsonRpcError(res, -32001, message, null, 503);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
initializingCount++;
|
|
397
|
+
try {
|
|
398
|
+
ipcTransport = new StreamableHTTPServerTransport({
|
|
399
|
+
sessionIdGenerator: () => randomUUID(),
|
|
400
|
+
onsessioninitialized: newSessionId => {
|
|
401
|
+
ipcTransports[newSessionId] = ipcTransport;
|
|
402
|
+
touchIpcSession(newSessionId);
|
|
403
|
+
},
|
|
404
|
+
onsessionclosed: closedSessionId => {
|
|
405
|
+
cleanupIpcSession(closedSessionId);
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
ipcTransport.onclose = () => {
|
|
409
|
+
if (ipcTransport?.sessionId) {
|
|
410
|
+
cleanupIpcSession(ipcTransport.sessionId);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
await server.connect(ipcTransport);
|
|
414
|
+
}
|
|
415
|
+
finally {
|
|
416
|
+
initializingCount = Math.max(0, initializingCount - 1);
|
|
417
|
+
drainInitQueue();
|
|
418
|
+
}
|
|
288
419
|
}
|
|
289
420
|
else {
|
|
290
|
-
res
|
|
291
|
-
jsonrpc: '2.0',
|
|
292
|
-
error: {
|
|
293
|
-
code: -32000,
|
|
294
|
-
message: 'Bad Request: No valid session ID provided',
|
|
295
|
-
},
|
|
296
|
-
id: null,
|
|
297
|
-
}));
|
|
421
|
+
sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided');
|
|
298
422
|
return;
|
|
299
423
|
}
|
|
300
424
|
try {
|
|
@@ -322,8 +446,13 @@ logDisclaimers();
|
|
|
322
446
|
res.writeHead(400).end('Invalid or missing session ID');
|
|
323
447
|
return;
|
|
324
448
|
}
|
|
449
|
+
touchPrimaryActivity();
|
|
450
|
+
touchIpcSession(sessionId);
|
|
325
451
|
try {
|
|
326
452
|
await ipcTransports[sessionId].handleRequest(req, res);
|
|
453
|
+
if (req.method === 'DELETE') {
|
|
454
|
+
cleanupIpcSession(sessionId);
|
|
455
|
+
}
|
|
327
456
|
}
|
|
328
457
|
catch (error) {
|
|
329
458
|
if (!res.headersSent) {
|
|
@@ -360,6 +489,16 @@ logDisclaimers();
|
|
|
360
489
|
}
|
|
361
490
|
});
|
|
362
491
|
ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
|
|
492
|
+
// Primary idle auto-exit: exit when no activity and no active IPC sessions
|
|
493
|
+
const primaryIdleCheckTimer = setInterval(() => {
|
|
494
|
+
const activeSessionCount = Object.keys(ipcTransports).length;
|
|
495
|
+
if (Date.now() - primaryLastActivityAt > ipcGuardConfig.primaryIdleMs &&
|
|
496
|
+
activeSessionCount === 0) {
|
|
497
|
+
logger(`[main] Primary idle for ${Math.round((Date.now() - primaryLastActivityAt) / 1000)}s with 0 active sessions. Auto-exiting.`);
|
|
498
|
+
shutdown('idle timeout');
|
|
499
|
+
}
|
|
500
|
+
}, 30_000);
|
|
501
|
+
primaryIdleCheckTimer.unref();
|
|
363
502
|
}
|
|
364
503
|
// Graceful shutdown handler with timeout
|
|
365
504
|
// Based on review: タイムアウト必須、強制終了タイマー必要
|
package/package.json
CHANGED
package/scripts/cli.mjs
CHANGED
|
@@ -2,48 +2,24 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* CLI Entry Point for chrome-ai-bridge
|
|
4
4
|
*
|
|
5
|
-
* This
|
|
6
|
-
*
|
|
7
|
-
* chrome-ai-bridge (if globally installed)
|
|
8
|
-
*
|
|
9
|
-
* Launches the MCP server with browser globals mock:
|
|
10
|
-
* - Loads browser-globals-mock.mjs BEFORE main.js
|
|
11
|
-
* - Ensures chrome-devtools-frontend modules work in Node.js
|
|
12
|
-
* - Simple execution: no wrapper, no hot-reload
|
|
5
|
+
* This entrypoint runs the MCP server in-process to avoid spawning an extra
|
|
6
|
+
* wrapper process per client (important for multi-pane usage).
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
|
-
import {spawn} from 'node:child_process';
|
|
16
|
-
import process from 'node:process';
|
|
17
|
-
import {fileURLToPath} from 'node:url';
|
|
18
9
|
import path from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
19
12
|
|
|
20
|
-
// Resolve paths
|
|
21
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
14
|
const mockPath = path.join(__dirname, 'browser-globals-mock.mjs');
|
|
23
15
|
const mainPath = path.join(__dirname, '..', 'build', 'src', 'main.js');
|
|
24
16
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
stdio: 'inherit',
|
|
36
|
-
env: process.env,
|
|
37
|
-
},
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
child.on('exit', (code, signal) => {
|
|
41
|
-
if (signal) {
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
process.exit(code ?? 0);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Forward signals
|
|
48
|
-
process.on('SIGTERM', () => child?.kill('SIGTERM'));
|
|
49
|
-
process.on('SIGINT', () => child?.kill('SIGINT'));
|
|
17
|
+
try {
|
|
18
|
+
// Ensure browser globals are defined before loading main server modules.
|
|
19
|
+
await import(pathToFileURL(mockPath).href);
|
|
20
|
+
await import(pathToFileURL(mainPath).href);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const message = error instanceof Error ? error.stack || error.message : String(error);
|
|
23
|
+
console.error(`[cli] Failed to start chrome-ai-bridge: ${message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|