abu-browser-bridge 0.5.1 → 0.6.6
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 +5 -4
- package/dist/tools.js +89 -19
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/dist/wsServer.d.ts +5 -2
- package/dist/wsServer.js +47 -33
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
17
17
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
18
|
import { startWSServer, stopWSServer } from './wsServer.js';
|
|
19
19
|
import { registerTools } from './tools.js';
|
|
20
|
+
import { PKG_VERSION } from './version.js';
|
|
20
21
|
const DEFAULT_WS_PORT = 9876;
|
|
21
22
|
const DISCOVERY_PORT = 9875;
|
|
22
23
|
/**
|
|
@@ -71,7 +72,7 @@ async function killStaleBridges(wsPort) {
|
|
|
71
72
|
}
|
|
72
73
|
for (const pid of pids) {
|
|
73
74
|
try {
|
|
74
|
-
|
|
75
|
+
process.kill(pid, 'SIGTERM');
|
|
75
76
|
console.error(`[abu-bridge] Killed stale process ${pid} (port scan)`);
|
|
76
77
|
}
|
|
77
78
|
catch { /* already dead */ }
|
|
@@ -79,8 +80,8 @@ async function killStaleBridges(wsPort) {
|
|
|
79
80
|
}
|
|
80
81
|
else {
|
|
81
82
|
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();
|
|
83
|
+
const portFlags = portsToCheck.map(p => `-i:${p}`);
|
|
84
|
+
const output = execSync(`lsof -t ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
84
85
|
if (output) {
|
|
85
86
|
const pids = new Set(output.split('\n').map(l => parseInt(l.trim(), 10)).filter(pid => pid && pid !== myPid));
|
|
86
87
|
for (const pid of pids) {
|
|
@@ -123,7 +124,7 @@ async function main() {
|
|
|
123
124
|
// 2. Create MCP server
|
|
124
125
|
const mcpServer = new McpServer({
|
|
125
126
|
name: 'abu-browser-bridge',
|
|
126
|
-
version:
|
|
127
|
+
version: PKG_VERSION,
|
|
127
128
|
});
|
|
128
129
|
// 3. Register browser tools
|
|
129
130
|
registerTools(mcpServer);
|
package/dist/tools.js
CHANGED
|
@@ -27,6 +27,36 @@ 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
|
|
@@ -37,7 +67,7 @@ export function registerTools(server) {
|
|
|
37
67
|
});
|
|
38
68
|
// 2. browser_snapshot
|
|
39
69
|
server.tool('snapshot', `Get a structured snapshot of all interactive elements on the page (buttons, inputs, links, selects, etc.). Returns each element with a short reference ID (e.g., "e1") that can be used in subsequent actions. This is the primary way to understand what's on a page before taking action.`, {
|
|
40
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
70
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
41
71
|
selector: z.string().optional().describe('Optional CSS selector to scope the snapshot to a specific area of the page'),
|
|
42
72
|
}, async ({ tabId, selector }) => {
|
|
43
73
|
ensureConnected();
|
|
@@ -46,55 +76,55 @@ export function registerTools(server) {
|
|
|
46
76
|
});
|
|
47
77
|
// 3. browser_click
|
|
48
78
|
server.tool('click', 'Click an element on the page. Returns the result of the click action.', {
|
|
49
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
79
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
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
|
});
|
|
57
87
|
// 4. browser_fill
|
|
58
88
|
server.tool('fill', 'Fill in a text input, textarea, or other editable field. Clears existing content and types the new value, triggering proper input/change events for framework compatibility (React, Vue, etc.).', {
|
|
59
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
89
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
60
90
|
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
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
|
});
|
|
68
98
|
// 5. browser_select
|
|
69
99
|
server.tool('select', 'Select an option from a <select> dropdown element.', {
|
|
70
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
100
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
71
101
|
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
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
|
});
|
|
79
109
|
// 6. browser_wait_for
|
|
80
110
|
server.tool('wait_for', `Wait for a condition to be met on the page. Useful for waiting for elements to appear after a click, waiting for loading to complete, or waiting for page navigation. Returns when the condition is met or times out.`, {
|
|
81
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
111
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
82
112
|
condition: z.string().describe(`JSON string of wait condition. Options:
|
|
83
113
|
- { "type": "appear", "locator": { "text": "成功" } } — wait for element to appear
|
|
84
114
|
- { "type": "disappear", "locator": { "css": ".loading" } } — wait for element to disappear
|
|
85
115
|
- { "type": "enabled", "locator": { "text": "提交" } } — wait for element to become clickable
|
|
86
116
|
- { "type": "textContains", "locator": { "css": "#status" }, "text": "完成" } — wait for text content
|
|
87
117
|
- { "type": "urlContains", "pattern": "/success" } — wait for URL change`),
|
|
88
|
-
timeout: z.number().optional().default(30000).describe('Maximum wait time in ms (default: 30000)'),
|
|
118
|
+
timeout: z.coerce.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
|
});
|
|
95
125
|
// 7. browser_extract_text
|
|
96
126
|
server.tool('extract_text', 'Extract text content from the page or a specific element. Useful for reading content, checking values, or verifying results.', {
|
|
97
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
127
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
98
128
|
selector: z.string().optional().describe('CSS selector to extract text from. If omitted, extracts the full page text (may be large).'),
|
|
99
129
|
}, async ({ tabId, selector }) => {
|
|
100
130
|
ensureConnected();
|
|
@@ -103,7 +133,7 @@ export function registerTools(server) {
|
|
|
103
133
|
});
|
|
104
134
|
// 8. browser_extract_table
|
|
105
135
|
server.tool('extract_table', 'Extract structured data from an HTML table on the page. Returns headers and rows as arrays.', {
|
|
106
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
136
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
107
137
|
selector: z.string().optional().describe('CSS selector for the target table. If omitted, extracts the largest table on the page.'),
|
|
108
138
|
}, async ({ tabId, selector }) => {
|
|
109
139
|
ensureConnected();
|
|
@@ -112,9 +142,9 @@ export function registerTools(server) {
|
|
|
112
142
|
});
|
|
113
143
|
// 9. browser_scroll
|
|
114
144
|
server.tool('scroll', 'Scroll the page or a specific element.', {
|
|
115
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
145
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
116
146
|
direction: z.enum(['up', 'down', 'left', 'right']).describe('Scroll direction'),
|
|
117
|
-
amount: z.number().optional().default(500).describe('Scroll amount in pixels (default: 500)'),
|
|
147
|
+
amount: z.coerce.number().optional().default(500).describe('Scroll amount in pixels (default: 500)'),
|
|
118
148
|
selector: z.string().optional().describe('CSS selector for the scrollable element. If omitted, scrolls the whole page.'),
|
|
119
149
|
}, async ({ tabId, direction, amount, selector }) => {
|
|
120
150
|
ensureConnected();
|
|
@@ -123,7 +153,7 @@ export function registerTools(server) {
|
|
|
123
153
|
});
|
|
124
154
|
// 10. browser_navigate
|
|
125
155
|
server.tool('navigate', 'Navigate a tab to a specific URL, or go back/forward in history.', {
|
|
126
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
156
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
127
157
|
url: z.string().optional().describe('URL to navigate to. Omit for back/forward.'),
|
|
128
158
|
action: z.enum(['goto', 'back', 'forward', 'reload']).optional().default('goto').describe('Navigation action (default: goto)'),
|
|
129
159
|
}, async ({ tabId, url, action }) => {
|
|
@@ -133,7 +163,7 @@ export function registerTools(server) {
|
|
|
133
163
|
});
|
|
134
164
|
// 11. browser_keyboard
|
|
135
165
|
server.tool('keyboard', 'Send keyboard events to the page. Supports key combinations.', {
|
|
136
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
166
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
137
167
|
key: z.string().describe('Key to press (e.g., "Enter", "Tab", "Escape", "a", "ArrowDown")'),
|
|
138
168
|
modifiers: z.array(z.enum(['ctrl', 'shift', 'alt', 'meta'])).optional().describe('Modifier keys to hold'),
|
|
139
169
|
}, async ({ tabId, key, modifiers }) => {
|
|
@@ -143,7 +173,7 @@ export function registerTools(server) {
|
|
|
143
173
|
});
|
|
144
174
|
// 12. browser_execute_js
|
|
145
175
|
server.tool('execute_js', 'Execute arbitrary JavaScript code in the context of the page. Use this as a fallback when other tools cannot achieve the desired result. Returns the result of the expression.', {
|
|
146
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
176
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
147
177
|
code: z.string().describe('JavaScript code to execute. The last expression value is returned.'),
|
|
148
178
|
}, async ({ tabId, code }) => {
|
|
149
179
|
ensureConnected();
|
|
@@ -152,7 +182,7 @@ export function registerTools(server) {
|
|
|
152
182
|
});
|
|
153
183
|
// 13. browser_screenshot
|
|
154
184
|
server.tool('screenshot', 'Take a screenshot of the visible area of a tab. Returns a base64-encoded PNG image. Useful for visual confirmation of actions.', {
|
|
155
|
-
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
185
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
156
186
|
}, async ({ tabId }) => {
|
|
157
187
|
ensureConnected();
|
|
158
188
|
const res = await sendToExtension('screenshot', { tabId });
|
|
@@ -167,7 +197,25 @@ export function registerTools(server) {
|
|
|
167
197
|
}
|
|
168
198
|
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
169
199
|
});
|
|
170
|
-
// 14.
|
|
200
|
+
// 14. browser_screenshot_full_page
|
|
201
|
+
server.tool('screenshot_full_page', 'Take a full-page screenshot by scrolling and stitching the entire page content. Returns a base64-encoded PNG image of the complete page. Use this when the user asks for a "long screenshot" or wants to capture content beyond the visible viewport. This is slower than a regular screenshot.', {
|
|
202
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
203
|
+
}, async ({ tabId }) => {
|
|
204
|
+
ensureConnected();
|
|
205
|
+
// Full-page capture needs more time: scroll + multiple captures + stitch
|
|
206
|
+
const res = await sendToExtension('screenshot_full_page', { tabId }, 120_000);
|
|
207
|
+
if (res.success && typeof res.data === 'string') {
|
|
208
|
+
return {
|
|
209
|
+
content: [{
|
|
210
|
+
type: 'image',
|
|
211
|
+
data: res.data.replace(/^data:image\/png;base64,/, ''),
|
|
212
|
+
mimeType: 'image/png',
|
|
213
|
+
}]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
217
|
+
});
|
|
218
|
+
// 15. browser_connection_status
|
|
171
219
|
server.tool('connection_status', 'Check whether the Chrome Extension is connected to this bridge. Use this to verify the extension is ready before performing browser actions.', async () => {
|
|
172
220
|
const connected = isExtensionConnected();
|
|
173
221
|
return {
|
|
@@ -179,4 +227,26 @@ export function registerTools(server) {
|
|
|
179
227
|
}]
|
|
180
228
|
};
|
|
181
229
|
});
|
|
230
|
+
// 15. get_downloads — recent download activity
|
|
231
|
+
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 () => {
|
|
232
|
+
ensureConnected();
|
|
233
|
+
const res = await sendToExtension('get_downloads');
|
|
234
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
235
|
+
});
|
|
236
|
+
// 16. start_recording — record user interactions
|
|
237
|
+
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.', {
|
|
238
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
239
|
+
}, async ({ tabId }) => {
|
|
240
|
+
ensureConnected();
|
|
241
|
+
const res = await sendToExtension('start_recording', { tabId });
|
|
242
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
243
|
+
});
|
|
244
|
+
// 17. stop_recording — stop recording and return captured steps
|
|
245
|
+
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.', {
|
|
246
|
+
tabId: z.coerce.number().describe('Tab ID from get_tabs'),
|
|
247
|
+
}, async ({ tabId }) => {
|
|
248
|
+
ensureConnected();
|
|
249
|
+
const res = await sendToExtension('stop_recording', { tabId });
|
|
250
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
251
|
+
});
|
|
182
252
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PKG_VERSION: string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
|
|
7
|
+
export const PKG_VERSION = pkg.version;
|
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';
|
|
15
|
+
import { PKG_VERSION } from './version.js';
|
|
11
16
|
const DEFAULT_WS_PORT = 9876;
|
|
12
17
|
const DISCOVERY_PORT = 9875;
|
|
13
|
-
const HEARTBEAT_INTERVAL = 15_000; // 15s
|
|
14
|
-
const PONG_TIMEOUT = 5_000; // If no pong within 5s, consider dead
|
|
18
|
+
const HEARTBEAT_INTERVAL = 15_000; // 15s
|
|
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:
|
|
73
|
+
version: PKG_VERSION,
|
|
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();
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abu-browser-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "MCP Server that bridges Abu AI assistant with Chrome Extension for browser automation",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"license": "
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"author": "pm-shawn",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/
|
|
10
|
+
"url": "https://github.com/PM-Shawn/Abu-Cowork"
|
|
11
11
|
},
|
|
12
12
|
"keywords": ["mcp", "browser", "automation", "chrome-extension", "abu"],
|
|
13
13
|
"bin": {
|