abu-browser-bridge 0.5.0 → 0.5.2
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/dist/index.js +4 -4
- package/dist/tools.js +57 -5
- package/dist/wsServer.d.ts +5 -2
- package/dist/wsServer.js +47 -33
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -71,7 +71,7 @@ async function killStaleBridges(wsPort) {
|
|
|
71
71
|
}
|
|
72
72
|
for (const pid of pids) {
|
|
73
73
|
try {
|
|
74
|
-
|
|
74
|
+
process.kill(pid, 'SIGTERM');
|
|
75
75
|
console.error(`[abu-bridge] Killed stale process ${pid} (port scan)`);
|
|
76
76
|
}
|
|
77
77
|
catch { /* already dead */ }
|
|
@@ -79,8 +79,8 @@ async function killStaleBridges(wsPort) {
|
|
|
79
79
|
}
|
|
80
80
|
else {
|
|
81
81
|
const { execSync } = await import('child_process');
|
|
82
|
-
const portFlags = portsToCheck.map(p => `-
|
|
83
|
-
const output = execSync(`lsof ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
82
|
+
const portFlags = portsToCheck.map(p => `-i:${p}`);
|
|
83
|
+
const output = execSync(`lsof -t ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
84
84
|
if (output) {
|
|
85
85
|
const pids = new Set(output.split('\n').map(l => parseInt(l.trim(), 10)).filter(pid => pid && pid !== myPid));
|
|
86
86
|
for (const pid of pids) {
|
|
@@ -123,7 +123,7 @@ async function main() {
|
|
|
123
123
|
// 2. Create MCP server
|
|
124
124
|
const mcpServer = new McpServer({
|
|
125
125
|
name: 'abu-browser-bridge',
|
|
126
|
-
version: '0.5.
|
|
126
|
+
version: '0.5.2',
|
|
127
127
|
});
|
|
128
128
|
// 3. Register browser tools
|
|
129
129
|
registerTools(mcpServer);
|
package/dist/tools.js
CHANGED
|
@@ -27,10 +27,40 @@ function formatResult(response) {
|
|
|
27
27
|
}
|
|
28
28
|
return JSON.stringify(response.data, null, 2);
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse and validate a JSON locator string from LLM input.
|
|
32
|
+
* Ensures the result is a plain object with at least one known locator key.
|
|
33
|
+
*/
|
|
34
|
+
function parseLocator(raw) {
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
37
|
+
throw new Error('Locator must be a JSON object');
|
|
38
|
+
}
|
|
39
|
+
const validKeys = ['css', 'text', 'tag', 'role', 'name', 'xpath', 'testId', 'ref'];
|
|
40
|
+
const hasValidKey = Object.keys(parsed).some(k => validKeys.includes(k));
|
|
41
|
+
if (!hasValidKey) {
|
|
42
|
+
throw new Error(`Locator must contain at least one of: ${validKeys.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse and validate a JSON wait condition string from LLM input.
|
|
48
|
+
*/
|
|
49
|
+
function parseCondition(raw) {
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
52
|
+
throw new Error('Condition must be a JSON object');
|
|
53
|
+
}
|
|
54
|
+
const validTypes = ['appear', 'disappear', 'enabled', 'textContains', 'urlContains'];
|
|
55
|
+
if (!validTypes.includes(parsed.type)) {
|
|
56
|
+
throw new Error(`Condition type must be one of: ${validTypes.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
30
60
|
// --- Register all tools ---
|
|
31
61
|
export function registerTools(server) {
|
|
32
62
|
// 1. browser_get_tabs
|
|
33
|
-
server.tool('get_tabs', 'Get
|
|
63
|
+
server.tool('get_tabs', 'Get all open Chrome browser tabs grouped by window. Returns a summary with the current window/tab info, plus a list of windows each containing their tabs. Use this first to find the target tab ID for other browser actions.', async () => {
|
|
34
64
|
ensureConnected();
|
|
35
65
|
const res = await sendToExtension('get_tabs');
|
|
36
66
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
@@ -50,7 +80,7 @@ export function registerTools(server) {
|
|
|
50
80
|
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
51
81
|
}, async ({ tabId, locator }) => {
|
|
52
82
|
ensureConnected();
|
|
53
|
-
const parsed =
|
|
83
|
+
const parsed = parseLocator(locator);
|
|
54
84
|
const res = await sendToExtension('click', { tabId, locator: parsed });
|
|
55
85
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
56
86
|
});
|
|
@@ -61,7 +91,7 @@ export function registerTools(server) {
|
|
|
61
91
|
value: z.string().describe('The text value to fill into the field'),
|
|
62
92
|
}, async ({ tabId, locator, value }) => {
|
|
63
93
|
ensureConnected();
|
|
64
|
-
const parsed =
|
|
94
|
+
const parsed = parseLocator(locator);
|
|
65
95
|
const res = await sendToExtension('fill', { tabId, locator: parsed, value });
|
|
66
96
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
67
97
|
});
|
|
@@ -72,7 +102,7 @@ export function registerTools(server) {
|
|
|
72
102
|
value: z.string().describe('The option value or visible text to select'),
|
|
73
103
|
}, async ({ tabId, locator, value }) => {
|
|
74
104
|
ensureConnected();
|
|
75
|
-
const parsed =
|
|
105
|
+
const parsed = parseLocator(locator);
|
|
76
106
|
const res = await sendToExtension('select', { tabId, locator: parsed, value });
|
|
77
107
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
78
108
|
});
|
|
@@ -88,7 +118,7 @@ export function registerTools(server) {
|
|
|
88
118
|
timeout: z.number().optional().default(30000).describe('Maximum wait time in ms (default: 30000)'),
|
|
89
119
|
}, async ({ tabId, condition, timeout }) => {
|
|
90
120
|
ensureConnected();
|
|
91
|
-
const parsed =
|
|
121
|
+
const parsed = parseCondition(condition);
|
|
92
122
|
const res = await sendToExtension('wait_for', { tabId, condition: parsed, timeout }, timeout + 5000);
|
|
93
123
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
94
124
|
});
|
|
@@ -179,4 +209,26 @@ export function registerTools(server) {
|
|
|
179
209
|
}]
|
|
180
210
|
};
|
|
181
211
|
});
|
|
212
|
+
// 15. get_downloads — recent download activity
|
|
213
|
+
server.tool('get_downloads', 'Get recent file downloads from the browser. Useful for confirming that a file was downloaded after clicking a download button.', async () => {
|
|
214
|
+
ensureConnected();
|
|
215
|
+
const res = await sendToExtension('get_downloads');
|
|
216
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
217
|
+
});
|
|
218
|
+
// 16. start_recording — record user interactions
|
|
219
|
+
server.tool('start_recording', 'Start recording user interactions on a page (clicks, inputs, selects). The user performs actions manually, then call stop_recording to get a list of recorded steps that can be used as an automation template.', {
|
|
220
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
221
|
+
}, async ({ tabId }) => {
|
|
222
|
+
ensureConnected();
|
|
223
|
+
const res = await sendToExtension('start_recording', { tabId });
|
|
224
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
225
|
+
});
|
|
226
|
+
// 17. stop_recording — stop recording and return captured steps
|
|
227
|
+
server.tool('stop_recording', 'Stop recording user interactions and return the captured steps. Each step includes the action type, element locator, and value. Use these steps as a template to replay the automation.', {
|
|
228
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
229
|
+
}, async ({ tabId }) => {
|
|
230
|
+
ensureConnected();
|
|
231
|
+
const res = await sendToExtension('stop_recording', { tabId });
|
|
232
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
233
|
+
});
|
|
182
234
|
}
|
package/dist/wsServer.d.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Also exposes a lightweight HTTP discovery endpoint on a fixed port
|
|
6
6
|
* so the Chrome Extension can reliably find the WS port.
|
|
7
|
+
*
|
|
8
|
+
* Security: Generates a random auth token on startup. The Chrome Extension
|
|
9
|
+
* must fetch this token from the discovery endpoint and include it as
|
|
10
|
+
* `Sec-WebSocket-Protocol` header when connecting.
|
|
7
11
|
*/
|
|
8
12
|
import type { BridgeResponse } from './types.js';
|
|
9
13
|
/**
|
|
10
14
|
* Start the WebSocket server on a fixed port.
|
|
11
|
-
*
|
|
12
|
-
* No port fallback — if the port is taken, it's a fatal error.
|
|
15
|
+
* Validates auth token from Sec-WebSocket-Protocol header on connection.
|
|
13
16
|
*/
|
|
14
17
|
export declare function startWSServer(port?: number): Promise<number>;
|
|
15
18
|
/**
|
package/dist/wsServer.js
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Also exposes a lightweight HTTP discovery endpoint on a fixed port
|
|
6
6
|
* so the Chrome Extension can reliably find the WS port.
|
|
7
|
+
*
|
|
8
|
+
* Security: Generates a random auth token on startup. The Chrome Extension
|
|
9
|
+
* must fetch this token from the discovery endpoint and include it as
|
|
10
|
+
* `Sec-WebSocket-Protocol` header when connecting.
|
|
7
11
|
*/
|
|
8
12
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
9
13
|
import { createServer } from 'http';
|
|
10
|
-
import {
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
11
15
|
const DEFAULT_WS_PORT = 9876;
|
|
12
16
|
const DISCOVERY_PORT = 9875;
|
|
13
|
-
const HEARTBEAT_INTERVAL = 15_000; // 15s
|
|
14
|
-
const PONG_TIMEOUT = 5_000;
|
|
17
|
+
const HEARTBEAT_INTERVAL = 15_000; // 15s
|
|
18
|
+
const PONG_TIMEOUT = 5_000;
|
|
15
19
|
let wss = null;
|
|
16
20
|
let discoveryServer = null;
|
|
17
21
|
let extensionSocket = null;
|
|
@@ -20,44 +24,40 @@ let pongReceived = true;
|
|
|
20
24
|
const pendingRequests = new Map();
|
|
21
25
|
let requestCounter = 0;
|
|
22
26
|
let activePort = null;
|
|
27
|
+
// Auth token — generated once per bridge process, shared via discovery endpoint
|
|
28
|
+
const authToken = randomBytes(24).toString('hex');
|
|
23
29
|
function generateId() {
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Check if a port is in use by trying to connect to it.
|
|
28
|
-
*/
|
|
29
|
-
function isPortInUse(port, host) {
|
|
30
|
-
return new Promise((resolve) => {
|
|
31
|
-
const socket = createConnection({ port, host });
|
|
32
|
-
socket.on('connect', () => {
|
|
33
|
-
socket.destroy();
|
|
34
|
-
resolve(true);
|
|
35
|
-
});
|
|
36
|
-
socket.on('error', () => {
|
|
37
|
-
socket.destroy();
|
|
38
|
-
resolve(false);
|
|
39
|
-
});
|
|
40
|
-
socket.setTimeout(1000, () => {
|
|
41
|
-
socket.destroy();
|
|
42
|
-
resolve(false);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
30
|
+
const rand = randomBytes(4).toString('hex');
|
|
31
|
+
return `req_${Date.now().toString(36)}_${(++requestCounter).toString(36)}_${rand}`;
|
|
45
32
|
}
|
|
46
33
|
// --- HTTP Discovery Endpoint ---
|
|
47
34
|
/**
|
|
48
35
|
* Start the HTTP discovery server on a fixed well-known port.
|
|
49
|
-
* Chrome Extension queries this to find the actual WS port.
|
|
36
|
+
* Chrome Extension queries this to find the actual WS port and auth token.
|
|
50
37
|
*
|
|
51
|
-
* GET /status → { wsPort, pid, extensionConnected, uptime }
|
|
38
|
+
* GET /status → { wsPort, pid, extensionConnected, uptime, version, token }
|
|
39
|
+
*
|
|
40
|
+
* CORS restricted to chrome-extension:// origins only.
|
|
52
41
|
*/
|
|
53
42
|
function startDiscoveryServer() {
|
|
54
43
|
return new Promise((resolve, reject) => {
|
|
55
44
|
const startTime = Date.now();
|
|
56
45
|
discoveryServer = createServer((req, res) => {
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
const origin = req.headers.origin ?? '';
|
|
47
|
+
// Only allow chrome-extension:// and no-origin (direct fetch from extension background)
|
|
48
|
+
const isAllowedOrigin = !origin || origin.startsWith('chrome-extension://');
|
|
49
|
+
if (!isAllowedOrigin) {
|
|
50
|
+
res.writeHead(403);
|
|
51
|
+
res.end('Forbidden');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// CORS headers for allowed origins
|
|
55
|
+
if (origin) {
|
|
56
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
57
|
+
}
|
|
59
58
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
60
59
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
60
|
+
res.setHeader('Vary', 'Origin');
|
|
61
61
|
if (req.method === 'OPTIONS') {
|
|
62
62
|
res.writeHead(204);
|
|
63
63
|
res.end();
|
|
@@ -70,7 +70,8 @@ function startDiscoveryServer() {
|
|
|
70
70
|
pid: process.pid,
|
|
71
71
|
extensionConnected: isExtensionConnected(),
|
|
72
72
|
uptime: Math.round((Date.now() - startTime) / 1000),
|
|
73
|
-
version: '0.5.
|
|
73
|
+
version: '0.5.2',
|
|
74
|
+
token: authToken,
|
|
74
75
|
}));
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
@@ -80,7 +81,7 @@ function startDiscoveryServer() {
|
|
|
80
81
|
discoveryServer.on('error', (err) => {
|
|
81
82
|
if (err.code === 'EADDRINUSE') {
|
|
82
83
|
console.error(`[abu-bridge] Discovery port ${DISCOVERY_PORT} in use — old bridge still running?`);
|
|
83
|
-
// Not fatal — extension can still fall back to port
|
|
84
|
+
// Not fatal — extension can still fall back to fixed port
|
|
84
85
|
resolve();
|
|
85
86
|
}
|
|
86
87
|
else {
|
|
@@ -96,8 +97,7 @@ function startDiscoveryServer() {
|
|
|
96
97
|
// --- WebSocket Server ---
|
|
97
98
|
/**
|
|
98
99
|
* Start the WebSocket server on a fixed port.
|
|
99
|
-
*
|
|
100
|
-
* No port fallback — if the port is taken, it's a fatal error.
|
|
100
|
+
* Validates auth token from Sec-WebSocket-Protocol header on connection.
|
|
101
101
|
*/
|
|
102
102
|
export async function startWSServer(port = DEFAULT_WS_PORT) {
|
|
103
103
|
await startDiscoveryServer();
|
|
@@ -107,7 +107,21 @@ export async function startWSServer(port = DEFAULT_WS_PORT) {
|
|
|
107
107
|
}
|
|
108
108
|
function listenOnPort(port) {
|
|
109
109
|
return new Promise((resolve, reject) => {
|
|
110
|
-
wss = new WebSocketServer({
|
|
110
|
+
wss = new WebSocketServer({
|
|
111
|
+
port,
|
|
112
|
+
host: '127.0.0.1',
|
|
113
|
+
verifyClient: (info, callback) => {
|
|
114
|
+
// Validate auth token from Sec-WebSocket-Protocol header
|
|
115
|
+
const protocol = info.req.headers['sec-websocket-protocol'];
|
|
116
|
+
if (protocol === authToken) {
|
|
117
|
+
callback(true);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.error(`[abu-bridge] Rejected WS connection: invalid auth token`);
|
|
121
|
+
callback(false, 401, 'Unauthorized');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
111
125
|
wss.on('listening', () => {
|
|
112
126
|
console.error(`[abu-bridge] WS server listening on ws://127.0.0.1:${port}`);
|
|
113
127
|
startHeartbeat();
|