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 CHANGED
@@ -71,7 +71,7 @@ async function killStaleBridges(wsPort) {
71
71
  }
72
72
  for (const pid of pids) {
73
73
  try {
74
- execSync(`taskkill /PID ${pid} /F`, { timeout: 3000 });
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 => `-ti:${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.0',
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 a list of all open browser tabs with their URLs and titles. Use this first to find the target tab.', async () => {
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 = JSON.parse(locator);
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 = JSON.parse(locator);
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 = JSON.parse(locator);
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 = JSON.parse(condition);
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
  }
@@ -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
- * The caller (index.ts) is responsible for killing stale bridges first.
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 { createConnection } from 'net';
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 — more frequent for faster disconnect detection
14
- const PONG_TIMEOUT = 5_000; // If no pong within 5s, consider dead
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
- return `req_${Date.now().toString(36)}_${(++requestCounter).toString(36)}`;
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
- // CORS headers for Chrome Extension fetch
58
- res.setHeader('Access-Control-Allow-Origin', '*');
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.0',
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 scanning
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
- * The caller (index.ts) is responsible for killing stale bridges first.
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({ port, host: '127.0.0.1' });
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abu-browser-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "MCP Server that bridges Abu AI assistant with Chrome Extension for browser automation",
5
5
  "type": "module",
6
6
  "license": "MIT",