@automatebrowser/mcp 0.3.0
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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +10 -0
- package/CHANGELOG.md +167 -0
- package/LICENSE +202 -0
- package/README.md +2092 -0
- package/dist/chunk-R5JREBXQ.js +211 -0
- package/dist/chunk-WAXVZKT5.js +5363 -0
- package/dist/cli.js +135 -0
- package/dist/index.js +287 -0
- package/dist/relay.js +919 -0
- package/package.json +80 -0
- package/skills/automate-browser/SKILL.md +181 -0
- package/skills/automate-browser/references/capture-and-diagnostics.md +486 -0
- package/skills/automate-browser/references/page-interaction.md +237 -0
- package/skills/automate-browser/references/reading-and-extraction.md +113 -0
- package/skills/automate-browser/references/sessions-and-state.md +186 -0
- package/skills/automate-browser/references/tabs-and-multi-agent.md +156 -0
- package/skills/automate-browser/references/tool-reference.md +236 -0
- package/skills/automate-browser/references/troubleshooting.md +136 -0
package/dist/relay.js
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RELAY_ROLE,
|
|
3
|
+
WHOLE_TAB,
|
|
4
|
+
createAuthChallenge,
|
|
5
|
+
debugLog,
|
|
6
|
+
getAuthToken,
|
|
7
|
+
isControlClaim,
|
|
8
|
+
isControlHello,
|
|
9
|
+
isControlRelease,
|
|
10
|
+
isControlSend,
|
|
11
|
+
isLoopbackHost,
|
|
12
|
+
log,
|
|
13
|
+
logPath,
|
|
14
|
+
mcpConfig,
|
|
15
|
+
parseFrame,
|
|
16
|
+
probePort,
|
|
17
|
+
verifyAuthResponse
|
|
18
|
+
} from "./chunk-R5JREBXQ.js";
|
|
19
|
+
|
|
20
|
+
// src/ws.ts
|
|
21
|
+
import { WebSocketServer } from "ws";
|
|
22
|
+
var MAX_BIND_ROUNDS = 3;
|
|
23
|
+
var INITIAL_BACKOFF_MS = 100;
|
|
24
|
+
var DEFAULT_MAX_PAYLOAD_BYTES = 64 * 1048576;
|
|
25
|
+
var DEFAULT_EXTENSION_ORIGIN = "chrome-extension://bjfgambnhccakkhmkepdoekmckoijdlc";
|
|
26
|
+
function configuredExtensionOrigins() {
|
|
27
|
+
const raw = process.env.AUTOMATE_BROWSER_EXTENSION_ORIGINS;
|
|
28
|
+
const values = raw ? raw.split(",").map((v) => v.trim()).filter(Boolean) : [DEFAULT_EXTENSION_ORIGIN];
|
|
29
|
+
return new Set(values);
|
|
30
|
+
}
|
|
31
|
+
function maxPayloadBytes() {
|
|
32
|
+
const n = Number(process.env.AUTOMATE_BROWSER_WS_MAX_PAYLOAD_BYTES);
|
|
33
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PAYLOAD_BYTES;
|
|
34
|
+
}
|
|
35
|
+
function isAllowedWsOrigin(origin) {
|
|
36
|
+
if (!origin) return true;
|
|
37
|
+
return configuredExtensionOrigins().has(origin);
|
|
38
|
+
}
|
|
39
|
+
function bindOnce(port, host) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const wss = new WebSocketServer({
|
|
42
|
+
host,
|
|
43
|
+
port,
|
|
44
|
+
maxPayload: maxPayloadBytes(),
|
|
45
|
+
verifyClient(info, done) {
|
|
46
|
+
if (isAllowedWsOrigin(info.origin)) {
|
|
47
|
+
done(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
log.warn(`[ws] rejected websocket origin ${info.origin || "(none)"}`);
|
|
51
|
+
done(false, 403, "Forbidden");
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const onError = (err) => {
|
|
55
|
+
wss.off("listening", onListening);
|
|
56
|
+
reject(err);
|
|
57
|
+
};
|
|
58
|
+
const onListening = () => {
|
|
59
|
+
wss.off("error", onError);
|
|
60
|
+
resolve(wss);
|
|
61
|
+
};
|
|
62
|
+
wss.once("error", onError);
|
|
63
|
+
wss.once("listening", onListening);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function createWebSocketServer(range = mcpConfig.wsPortRange, host = "127.0.0.1") {
|
|
67
|
+
const [start, end] = range;
|
|
68
|
+
let lastErr;
|
|
69
|
+
for (let round = 0; round < MAX_BIND_ROUNDS; round++) {
|
|
70
|
+
for (let port = start; port <= end; port++) {
|
|
71
|
+
try {
|
|
72
|
+
const wss = await bindOnce(port, host);
|
|
73
|
+
log.info(`[ws] listening on ws://${host}:${port}`);
|
|
74
|
+
return { wss, port };
|
|
75
|
+
} catch (err) {
|
|
76
|
+
lastErr = err;
|
|
77
|
+
if (err?.code !== "EADDRINUSE") {
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
debugLog(`[ws] port ${port} in use, trying next`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const backoff = INITIAL_BACKOFF_MS * 2 ** round;
|
|
84
|
+
debugLog(
|
|
85
|
+
`[ws] ports ${start}-${end} all in use (round ${round + 1}/${MAX_BIND_ROUNDS}), retrying in ${backoff}ms`
|
|
86
|
+
);
|
|
87
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
88
|
+
}
|
|
89
|
+
throw new Error(
|
|
90
|
+
`No free port in range ${start}-${end}. Another automate-browser instance is likely running \u2014 close it and try again. Last error: ${String(lastErr)}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/relay/log-file.ts
|
|
95
|
+
import { appendFileSync } from "fs";
|
|
96
|
+
var LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
97
|
+
var minLevel = LEVELS[(process.env.AUTOMATE_BROWSER_LOG_LEVEL ?? "info").toLowerCase()] ?? LEVELS.info;
|
|
98
|
+
var foreground = process.env.AUTOMATE_BROWSER_RELAY_FOREGROUND === "1";
|
|
99
|
+
var RELAY_LOG_PATH = logPath("automate-browser-relay.log");
|
|
100
|
+
function emit(level, args) {
|
|
101
|
+
if (LEVELS[level] < minLevel) return;
|
|
102
|
+
const body = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
103
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [relay] [${level}] ${body}`;
|
|
104
|
+
try {
|
|
105
|
+
appendFileSync(RELAY_LOG_PATH, line + "\n");
|
|
106
|
+
} catch {
|
|
107
|
+
}
|
|
108
|
+
if (foreground) {
|
|
109
|
+
console.error(line);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
var rlog = {
|
|
113
|
+
debug: (...a) => emit("debug", a),
|
|
114
|
+
info: (...a) => emit("info", a),
|
|
115
|
+
warn: (...a) => emit("warn", a),
|
|
116
|
+
error: (...a) => emit("error", a)
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/relay/relay.ts
|
|
120
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
121
|
+
|
|
122
|
+
// src/relay/browsers.ts
|
|
123
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
124
|
+
|
|
125
|
+
// src/vendor/messaging/ws-sender.ts
|
|
126
|
+
import { randomUUID } from "crypto";
|
|
127
|
+
import { WebSocket } from "ws";
|
|
128
|
+
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
129
|
+
function parseResponse(raw) {
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(String(raw));
|
|
132
|
+
return parsed !== null && typeof parsed === "object" ? parsed : void 0;
|
|
133
|
+
} catch {
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function createSocketMessageSender(ws) {
|
|
138
|
+
function sendSocketMessage(type, payload, options = { timeoutMs: DEFAULT_TIMEOUT_MS }) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
141
|
+
reject(new Error("WebSocket is not connected"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const id = randomUUID();
|
|
145
|
+
const cleanup = () => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
ws.off("message", onMessage);
|
|
148
|
+
};
|
|
149
|
+
const onMessage = (raw) => {
|
|
150
|
+
const msg = parseResponse(raw);
|
|
151
|
+
if (!msg || msg.type !== "messageResponse") return;
|
|
152
|
+
const body = msg.payload;
|
|
153
|
+
if (!body || body.requestId !== id) return;
|
|
154
|
+
cleanup();
|
|
155
|
+
if (body.error) {
|
|
156
|
+
const err = body.error;
|
|
157
|
+
reject(
|
|
158
|
+
new Error(
|
|
159
|
+
typeof err === "string" ? err : err?.message || JSON.stringify(err)
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
resolve(body.result);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
cleanup();
|
|
168
|
+
reject(new Error("Socket message timeout"));
|
|
169
|
+
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
170
|
+
ws.on("message", onMessage);
|
|
171
|
+
try {
|
|
172
|
+
ws.send(JSON.stringify({ id, type, payload }));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
cleanup();
|
|
175
|
+
reject(error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { sendSocketMessage };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/relay/browsers.ts
|
|
183
|
+
var BrowserRegistry = class {
|
|
184
|
+
_map = /* @__PURE__ */ new Map();
|
|
185
|
+
add(ws, meta) {
|
|
186
|
+
const id = randomUUID2();
|
|
187
|
+
const sender = createSocketMessageSender(ws).sendSocketMessage;
|
|
188
|
+
this._map.set(id, { id, ws, sender, meta, claims: /* @__PURE__ */ new Map() });
|
|
189
|
+
return id;
|
|
190
|
+
}
|
|
191
|
+
updateMeta(id, partial) {
|
|
192
|
+
const c = this._map.get(id);
|
|
193
|
+
if (!c) return;
|
|
194
|
+
Object.assign(
|
|
195
|
+
c.meta,
|
|
196
|
+
Object.fromEntries(Object.entries(partial).filter(([, v]) => v !== void 0))
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
remove(id) {
|
|
200
|
+
this._map.delete(id);
|
|
201
|
+
}
|
|
202
|
+
get(id) {
|
|
203
|
+
return this._map.get(id);
|
|
204
|
+
}
|
|
205
|
+
list() {
|
|
206
|
+
return [...this._map.values()];
|
|
207
|
+
}
|
|
208
|
+
size() {
|
|
209
|
+
return this._map.size;
|
|
210
|
+
}
|
|
211
|
+
/** The live (non-expired) claim on a given tab key, lazily clearing a lapsed one. */
|
|
212
|
+
liveClaim(id, tabKey) {
|
|
213
|
+
const c = this._map.get(id);
|
|
214
|
+
const claim = c?.claims.get(tabKey);
|
|
215
|
+
if (!claim) return void 0;
|
|
216
|
+
if (claim.leaseExpiry <= Date.now()) {
|
|
217
|
+
c.claims.delete(tabKey);
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
return claim;
|
|
221
|
+
}
|
|
222
|
+
/** All live claims on a browser (lazily clearing lapsed ones), with their tab ids. */
|
|
223
|
+
liveClaimsFor(id) {
|
|
224
|
+
const c = this._map.get(id);
|
|
225
|
+
if (!c) return [];
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const [tabId, claim] of c.claims) {
|
|
229
|
+
if (claim.leaseExpiry <= now) {
|
|
230
|
+
c.claims.delete(tabId);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
out.push({ ...claim, tabId });
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
setClaim(id, tabKey, claim) {
|
|
238
|
+
const c = this._map.get(id);
|
|
239
|
+
if (c) c.claims.set(tabKey, claim);
|
|
240
|
+
}
|
|
241
|
+
clearClaim(id, tabKey) {
|
|
242
|
+
const c = this._map.get(id);
|
|
243
|
+
c?.claims.delete(tabKey);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Drop every claim this controller holds on one browser. Returns the tab keys
|
|
247
|
+
* that were freed (so the caller can decide whether to broadcast).
|
|
248
|
+
*/
|
|
249
|
+
releaseClaimsOnBrowser(id, controllerId) {
|
|
250
|
+
const c = this._map.get(id);
|
|
251
|
+
if (!c) return [];
|
|
252
|
+
const freed = [];
|
|
253
|
+
for (const [tabKey, claim] of c.claims) {
|
|
254
|
+
if (claim.controllerId === controllerId) {
|
|
255
|
+
c.claims.delete(tabKey);
|
|
256
|
+
freed.push(tabKey);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return freed;
|
|
260
|
+
}
|
|
261
|
+
/** Drop every claim owned by a controller; returns the affected {browserId, tabId}. */
|
|
262
|
+
releaseByController(controllerId) {
|
|
263
|
+
const hit = [];
|
|
264
|
+
for (const c of this._map.values()) {
|
|
265
|
+
for (const [tabKey, claim] of c.claims) {
|
|
266
|
+
if (claim.controllerId === controllerId) {
|
|
267
|
+
c.claims.delete(tabKey);
|
|
268
|
+
hit.push({ browserId: c.id, tabId: tabKey });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return hit;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Serialisable roster for control frames. `active` is decided per-controller
|
|
276
|
+
* (Context recomputes it), so it is always false here. Live (non-expired)
|
|
277
|
+
* claims are included so controllers can show who is driving each tab.
|
|
278
|
+
*/
|
|
279
|
+
info() {
|
|
280
|
+
return this.list().map((c) => {
|
|
281
|
+
const claims = this.liveClaimsFor(c.id);
|
|
282
|
+
return {
|
|
283
|
+
...c.meta,
|
|
284
|
+
id: c.id,
|
|
285
|
+
active: false,
|
|
286
|
+
...claims.length ? { claims } : {}
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// src/relay/relay.ts
|
|
293
|
+
var ROLE_TIMEOUT_MS = 3e3;
|
|
294
|
+
var HEARTBEAT_INTERVAL_MS = 2e4;
|
|
295
|
+
var IDLE_EXIT_MS = numEnv("AUTOMATE_BROWSER_RELAY_IDLE_MS", 3e5);
|
|
296
|
+
var LEASE_TTL_MS = numEnv("AUTOMATE_BROWSER_LEASE_TTL_MS", 6e4);
|
|
297
|
+
var RATE_WINDOW_MS = numEnv("AUTOMATE_BROWSER_WS_RATE_WINDOW_MS", 1e3);
|
|
298
|
+
var RATE_MAX_MESSAGES = numEnv("AUTOMATE_BROWSER_WS_RATE_MAX", 120);
|
|
299
|
+
var CONTROLLER_STALE_MS = numEnv("AUTOMATE_BROWSER_CONTROLLER_STALE_MS", 45e3);
|
|
300
|
+
var REAP_INTERVAL_MS = Math.max(1e3, Math.floor(CONTROLLER_STALE_MS / 3));
|
|
301
|
+
function numEnv(name, fallback) {
|
|
302
|
+
const n = Number(process.env[name]);
|
|
303
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
304
|
+
}
|
|
305
|
+
function safeClose(ws, code = 1008, reason = "policy violation") {
|
|
306
|
+
try {
|
|
307
|
+
ws.close(code, reason);
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function isPlainObject(value) {
|
|
312
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
313
|
+
}
|
|
314
|
+
function isTabGone(msg) {
|
|
315
|
+
return /no tab with id|no drivable tab|tab .*not found|invalid tab id|cannot access a chrome/i.test(
|
|
316
|
+
msg
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
function isIdentifyFrame(msg) {
|
|
320
|
+
return isPlainObject(msg) && msg.type === "identify" && isPlainObject(msg.payload);
|
|
321
|
+
}
|
|
322
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
323
|
+
function validEnvelope(msg) {
|
|
324
|
+
return isPlainObject(msg) && typeof msg.type === "string" && (msg.id === void 0 || typeof msg.id === "string") && (msg.payload === void 0 || isPlainObject(msg.payload));
|
|
325
|
+
}
|
|
326
|
+
function startRelay(wss, port, version2, host = "127.0.0.1") {
|
|
327
|
+
const browsers = new BrowserRegistry();
|
|
328
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
329
|
+
const token = getAuthToken();
|
|
330
|
+
let idleTimer;
|
|
331
|
+
const peerCount = () => browsers.size() + controllers.size;
|
|
332
|
+
function armIdleExit() {
|
|
333
|
+
if (idleTimer || peerCount() > 0) return;
|
|
334
|
+
idleTimer = setTimeout(() => {
|
|
335
|
+
if (peerCount() === 0) {
|
|
336
|
+
rlog.info("idle with no peers; exiting");
|
|
337
|
+
try {
|
|
338
|
+
wss.close();
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
process.exit(0);
|
|
342
|
+
}
|
|
343
|
+
}, IDLE_EXIT_MS);
|
|
344
|
+
idleTimer.unref?.();
|
|
345
|
+
}
|
|
346
|
+
function cancelIdleExit() {
|
|
347
|
+
if (idleTimer) {
|
|
348
|
+
clearTimeout(idleTimer);
|
|
349
|
+
idleTimer = void 0;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function displayName(c) {
|
|
353
|
+
for (const other of controllers.values()) {
|
|
354
|
+
if (other.id !== c.id && other.name === c.name) {
|
|
355
|
+
return c.pid ? `${c.name} (pid ${c.pid})` : `${c.name} (${c.id.slice(0, 8)})`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return c.name;
|
|
359
|
+
}
|
|
360
|
+
function nameOf(id) {
|
|
361
|
+
const c = id ? controllers.get(id) : void 0;
|
|
362
|
+
return c ? displayName(c) : "an agent";
|
|
363
|
+
}
|
|
364
|
+
function dropController(id, reason) {
|
|
365
|
+
const c = controllers.get(id);
|
|
366
|
+
if (!c) return;
|
|
367
|
+
const freed = browsers.releaseByController(id);
|
|
368
|
+
controllers.delete(id);
|
|
369
|
+
rlog.info(
|
|
370
|
+
`controller dropped id=${id.slice(0, 8)} name="${c.name}" reason=${reason} freed=${freed.length} (controllers=${controllers.size})`
|
|
371
|
+
);
|
|
372
|
+
safeClose(c.ws, 1e3, reason);
|
|
373
|
+
}
|
|
374
|
+
function peerList() {
|
|
375
|
+
return [...controllers.values()].map((c) => ({ id: c.id, name: displayName(c) }));
|
|
376
|
+
}
|
|
377
|
+
function broadcastBrowsers() {
|
|
378
|
+
if (controllers.size === 0) return;
|
|
379
|
+
const browsersInfo = browsers.info();
|
|
380
|
+
const peers = peerList();
|
|
381
|
+
for (const c of controllers.values()) {
|
|
382
|
+
try {
|
|
383
|
+
c.ws.send(
|
|
384
|
+
JSON.stringify({
|
|
385
|
+
type: "control.browsers",
|
|
386
|
+
payload: {
|
|
387
|
+
browsers: browsersInfo,
|
|
388
|
+
controllers: peers.map((p) => ({ ...p, self: p.id === c.id }))
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function broadcastAgents() {
|
|
397
|
+
if (browsers.size() === 0) return;
|
|
398
|
+
const peers = peerList();
|
|
399
|
+
for (const b of browsers.list()) {
|
|
400
|
+
const claims = browsers.liveClaimsFor(b.id);
|
|
401
|
+
try {
|
|
402
|
+
b.ws.send(JSON.stringify({ type: "agents", payload: { claims, controllers: peers } }));
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function broadcastRoster() {
|
|
408
|
+
broadcastBrowsers();
|
|
409
|
+
broadcastAgents();
|
|
410
|
+
}
|
|
411
|
+
const reaper = setInterval(() => {
|
|
412
|
+
if (controllers.size === 0) return;
|
|
413
|
+
const cutoff = Date.now() - CONTROLLER_STALE_MS;
|
|
414
|
+
const stale = [...controllers.values()].filter((c) => c.lastSeenAt < cutoff);
|
|
415
|
+
if (stale.length === 0) return;
|
|
416
|
+
for (const c of stale) dropController(c.id, "stale");
|
|
417
|
+
broadcastRoster();
|
|
418
|
+
armIdleExit();
|
|
419
|
+
}, REAP_INTERVAL_MS);
|
|
420
|
+
reaper.unref?.();
|
|
421
|
+
wss.on("connection", (ws) => {
|
|
422
|
+
cancelIdleExit();
|
|
423
|
+
const authChallenge = token ? createAuthChallenge() : void 0;
|
|
424
|
+
try {
|
|
425
|
+
ws.send(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
id: randomUUID3(),
|
|
428
|
+
type: "hello",
|
|
429
|
+
payload: {
|
|
430
|
+
server: "automate-browser",
|
|
431
|
+
version: version2,
|
|
432
|
+
port,
|
|
433
|
+
role: RELAY_ROLE,
|
|
434
|
+
...authChallenge ? { auth: authChallenge } : {}
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
} catch {
|
|
439
|
+
}
|
|
440
|
+
let role;
|
|
441
|
+
let browserId;
|
|
442
|
+
let controllerId;
|
|
443
|
+
let rateWindowStartedAt = Date.now();
|
|
444
|
+
let rateCount = 0;
|
|
445
|
+
const roleTimer = setTimeout(() => {
|
|
446
|
+
if (!role) {
|
|
447
|
+
try {
|
|
448
|
+
ws.close();
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}, ROLE_TIMEOUT_MS);
|
|
453
|
+
roleTimer.unref?.();
|
|
454
|
+
let alive = true;
|
|
455
|
+
const onPong = () => {
|
|
456
|
+
alive = true;
|
|
457
|
+
};
|
|
458
|
+
ws.on("pong", onPong);
|
|
459
|
+
const hb = setInterval(() => {
|
|
460
|
+
if (!alive) {
|
|
461
|
+
try {
|
|
462
|
+
ws.terminate();
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
clearInterval(hb);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
alive = false;
|
|
469
|
+
try {
|
|
470
|
+
ws.ping();
|
|
471
|
+
} catch {
|
|
472
|
+
clearInterval(hb);
|
|
473
|
+
}
|
|
474
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
475
|
+
hb.unref?.();
|
|
476
|
+
async function handleControlSend(frame) {
|
|
477
|
+
const {
|
|
478
|
+
requestId,
|
|
479
|
+
browserId: targetId,
|
|
480
|
+
tabId,
|
|
481
|
+
noClaim,
|
|
482
|
+
toolType,
|
|
483
|
+
toolPayload,
|
|
484
|
+
timeoutMs
|
|
485
|
+
} = frame.payload;
|
|
486
|
+
const reply = (result, error, extra) => {
|
|
487
|
+
try {
|
|
488
|
+
ws.send(
|
|
489
|
+
JSON.stringify({
|
|
490
|
+
type: "control.response",
|
|
491
|
+
payload: { requestId, result, error, ...extra ?? {} }
|
|
492
|
+
})
|
|
493
|
+
);
|
|
494
|
+
} catch {
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const target = targetId ? browsers.get(targetId) : browsers.size() === 1 ? browsers.list()[0] : void 0;
|
|
498
|
+
if (!target) {
|
|
499
|
+
reply(void 0, "No connected tab found", { errorCode: "no_browser" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const me = controllerId;
|
|
503
|
+
const ctrl = controllers.get(me);
|
|
504
|
+
const myName = nameOf(me);
|
|
505
|
+
const takeNotice = () => {
|
|
506
|
+
const n = ctrl?.takeoverNotices.get(target.id);
|
|
507
|
+
if (n) ctrl.takeoverNotices.delete(target.id);
|
|
508
|
+
return n;
|
|
509
|
+
};
|
|
510
|
+
const forwardPayload = tabId != null && isPlainObject(toolPayload) ? { ...toolPayload, __bmcpTabId: tabId } : toolPayload;
|
|
511
|
+
const forward = async (notice) => {
|
|
512
|
+
try {
|
|
513
|
+
const result = await target.sender(toolType, forwardPayload, {
|
|
514
|
+
timeoutMs
|
|
515
|
+
});
|
|
516
|
+
reply(result, void 0, notice ? { notice } : void 0);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
const msg = e?.message || String(e);
|
|
519
|
+
if (tabId != null && isTabGone(msg)) {
|
|
520
|
+
browsers.clearClaim(target.id, tabId);
|
|
521
|
+
broadcastRoster();
|
|
522
|
+
reply(void 0, msg, {
|
|
523
|
+
errorCode: "tab_gone",
|
|
524
|
+
browserId: target.id,
|
|
525
|
+
tabId
|
|
526
|
+
});
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
reply(void 0, msg);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
if (noClaim) {
|
|
533
|
+
await forward();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const tabKey = tabId ?? WHOLE_TAB;
|
|
537
|
+
const live = browsers.liveClaimsFor(target.id);
|
|
538
|
+
const conflict = tabKey === WHOLE_TAB ? (
|
|
539
|
+
// A whole-browser drive conflicts with ANY claim by another controller.
|
|
540
|
+
live.find((c) => c.controllerId !== me)
|
|
541
|
+
) : (
|
|
542
|
+
// A tab drive conflicts only with a WHOLE held by another, or the SAME tab.
|
|
543
|
+
live.find(
|
|
544
|
+
(c) => c.controllerId !== me && (c.tabId === WHOLE_TAB || c.tabId === tabKey)
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
if (conflict) {
|
|
548
|
+
reply(void 0, "claimed", {
|
|
549
|
+
errorCode: "claimed",
|
|
550
|
+
claimedBy: conflict.controllerName,
|
|
551
|
+
browserId: target.id,
|
|
552
|
+
tabId: conflict.tabId,
|
|
553
|
+
leaseExpiry: conflict.leaseExpiry,
|
|
554
|
+
notice: takeNotice()
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const wasUnclaimed = !browsers.liveClaim(target.id, tabKey);
|
|
559
|
+
browsers.setClaim(target.id, tabKey, {
|
|
560
|
+
controllerId: me,
|
|
561
|
+
controllerName: myName,
|
|
562
|
+
leaseExpiry: Date.now() + LEASE_TTL_MS
|
|
563
|
+
});
|
|
564
|
+
if (wasUnclaimed) broadcastRoster();
|
|
565
|
+
await forward(takeNotice());
|
|
566
|
+
}
|
|
567
|
+
function handleControlClaim(frame) {
|
|
568
|
+
const { requestId, browserId: browserId2, tabId, force } = frame.payload;
|
|
569
|
+
const send = (p) => {
|
|
570
|
+
try {
|
|
571
|
+
ws.send(JSON.stringify({ type: "control.claimResult", payload: { requestId, ...p } }));
|
|
572
|
+
} catch {
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const target = browsers.get(browserId2);
|
|
576
|
+
if (!target) {
|
|
577
|
+
send({ ok: false, errorCode: "no_browser", browserId: browserId2 });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const me = controllerId;
|
|
581
|
+
const tabKey = tabId ?? WHOLE_TAB;
|
|
582
|
+
const live = browsers.liveClaimsFor(browserId2);
|
|
583
|
+
const blockers = tabKey === WHOLE_TAB ? live.filter((c) => c.controllerId !== me) : live.filter(
|
|
584
|
+
(c) => c.controllerId !== me && (c.tabId === WHOLE_TAB || c.tabId === tabKey)
|
|
585
|
+
);
|
|
586
|
+
if (blockers.length && !force) {
|
|
587
|
+
const b = blockers[0];
|
|
588
|
+
send({
|
|
589
|
+
ok: false,
|
|
590
|
+
errorCode: "claimed",
|
|
591
|
+
browserId: browserId2,
|
|
592
|
+
tabId: b.tabId,
|
|
593
|
+
claimedBy: b.controllerName,
|
|
594
|
+
leaseExpiry: b.leaseExpiry
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (blockers.length && force) {
|
|
599
|
+
for (const b of blockers) {
|
|
600
|
+
const prev = controllers.get(b.controllerId);
|
|
601
|
+
const which = b.tabId === WHOLE_TAB ? "" : ` (tab ${b.tabId})`;
|
|
602
|
+
const message = `Heads up: your control of ${target.meta.browser}` + (target.meta.label ? ` "${target.meta.label}"` : "") + `${which} was taken over by "${nameOf(me)}".`;
|
|
603
|
+
let pushed = false;
|
|
604
|
+
if (prev) {
|
|
605
|
+
try {
|
|
606
|
+
prev.ws.send(
|
|
607
|
+
JSON.stringify({
|
|
608
|
+
type: "control.leaseLost",
|
|
609
|
+
payload: {
|
|
610
|
+
browserId: browserId2,
|
|
611
|
+
...b.tabId === WHOLE_TAB ? {} : { tabId: b.tabId },
|
|
612
|
+
takenBy: nameOf(me),
|
|
613
|
+
message
|
|
614
|
+
}
|
|
615
|
+
})
|
|
616
|
+
);
|
|
617
|
+
pushed = true;
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!pushed) prev?.takeoverNotices.set(browserId2, message);
|
|
622
|
+
browsers.clearClaim(browserId2, b.tabId);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const claim = {
|
|
626
|
+
controllerId: me,
|
|
627
|
+
controllerName: nameOf(me),
|
|
628
|
+
leaseExpiry: Date.now() + LEASE_TTL_MS
|
|
629
|
+
};
|
|
630
|
+
browsers.setClaim(browserId2, tabKey, claim);
|
|
631
|
+
broadcastRoster();
|
|
632
|
+
send({ ok: true, browserId: browserId2, tabId: tabKey, claim });
|
|
633
|
+
}
|
|
634
|
+
function handleControlRelease(frame) {
|
|
635
|
+
const { requestId, browserId: browserId2, tabId } = frame.payload;
|
|
636
|
+
const me = controllerId;
|
|
637
|
+
let released = false;
|
|
638
|
+
if (browserId2) {
|
|
639
|
+
if (tabId != null) {
|
|
640
|
+
const live = browsers.liveClaim(browserId2, tabId);
|
|
641
|
+
if (live && live.controllerId === me) {
|
|
642
|
+
browsers.clearClaim(browserId2, tabId);
|
|
643
|
+
released = true;
|
|
644
|
+
}
|
|
645
|
+
} else {
|
|
646
|
+
released = browsers.releaseClaimsOnBrowser(browserId2, me).length > 0;
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
released = browsers.releaseByController(me).length > 0;
|
|
650
|
+
}
|
|
651
|
+
if (released) broadcastRoster();
|
|
652
|
+
try {
|
|
653
|
+
ws.send(
|
|
654
|
+
JSON.stringify({
|
|
655
|
+
type: "control.claimResult",
|
|
656
|
+
payload: { requestId, ok: true, browserId: browserId2 }
|
|
657
|
+
})
|
|
658
|
+
);
|
|
659
|
+
} catch {
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const onMessage = (raw) => {
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
if (now - rateWindowStartedAt > RATE_WINDOW_MS) {
|
|
665
|
+
rateWindowStartedAt = now;
|
|
666
|
+
rateCount = 0;
|
|
667
|
+
}
|
|
668
|
+
rateCount += 1;
|
|
669
|
+
if (rateCount > RATE_MAX_MESSAGES) {
|
|
670
|
+
rlog.info("socket rejected \u2014 websocket message rate limit exceeded");
|
|
671
|
+
safeClose(ws, 1008, "rate limit exceeded");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const msg = parseFrame(raw);
|
|
675
|
+
if (!msg) {
|
|
676
|
+
rlog.info("socket rejected \u2014 frame is not a JSON object");
|
|
677
|
+
safeClose(ws, 1003, "malformed frame");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (!validEnvelope(msg)) {
|
|
681
|
+
rlog.info("socket rejected \u2014 invalid frame envelope");
|
|
682
|
+
safeClose(ws, 1003, "invalid frame");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (role === "controller" && controllerId) {
|
|
686
|
+
const me = controllers.get(controllerId);
|
|
687
|
+
if (me) me.lastSeenAt = now;
|
|
688
|
+
}
|
|
689
|
+
if (msg.type === "ping") {
|
|
690
|
+
try {
|
|
691
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (msg.type === "control.ping") {
|
|
697
|
+
try {
|
|
698
|
+
ws.send(JSON.stringify({ type: "control.pong" }));
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (msg.type === "control.bye") {
|
|
704
|
+
try {
|
|
705
|
+
ws.close();
|
|
706
|
+
} catch {
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (!role) {
|
|
711
|
+
if (isIdentifyFrame(msg)) {
|
|
712
|
+
if (token && !verifyAuthResponse(token, authChallenge?.challenge ?? "", msg.payload.auth)) {
|
|
713
|
+
rlog.info("browser identify rejected \u2014 auth proof mismatch; closing");
|
|
714
|
+
safeClose(ws, 1008, "auth failed");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
role = "browser";
|
|
718
|
+
clearTimeout(roleTimer);
|
|
719
|
+
const p = msg.payload ?? {};
|
|
720
|
+
const meta = {
|
|
721
|
+
browser: str(p.browser) || "unknown",
|
|
722
|
+
browserVersion: str(p.browserVersion),
|
|
723
|
+
label: str(p.label),
|
|
724
|
+
instanceId: str(p.instanceId),
|
|
725
|
+
tabId: typeof p.tabId === "number" ? p.tabId : void 0,
|
|
726
|
+
tabUrl: str(p.tabUrl),
|
|
727
|
+
tabTitle: str(p.tabTitle),
|
|
728
|
+
connectedAt: Date.now()
|
|
729
|
+
};
|
|
730
|
+
browserId = browsers.add(ws, meta);
|
|
731
|
+
rlog.info(
|
|
732
|
+
`browser connected id=${browserId.slice(0, 8)} browser=${meta.browser}` + (meta.label ? ` label="${meta.label}"` : "") + ` (browsers=${browsers.size()})`
|
|
733
|
+
);
|
|
734
|
+
broadcastRoster();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (isControlHello(msg)) {
|
|
738
|
+
if (token && !verifyAuthResponse(token, authChallenge?.challenge ?? "", msg.payload.auth)) {
|
|
739
|
+
rlog.info("controller rejected \u2014 auth proof mismatch; closing");
|
|
740
|
+
safeClose(ws, 1008, "auth failed");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
role = "controller";
|
|
744
|
+
clearTimeout(roleTimer);
|
|
745
|
+
controllerId = randomUUID3();
|
|
746
|
+
const ctrlName = msg.payload?.name && String(msg.payload.name).trim() || `mcp-${msg.payload?.pid ?? "?"}`;
|
|
747
|
+
const instanceId = msg.payload?.instanceId && String(msg.payload.instanceId).trim() || void 0;
|
|
748
|
+
if (instanceId) {
|
|
749
|
+
for (const prev of controllers.values()) {
|
|
750
|
+
if (prev.instanceId === instanceId) dropController(prev.id, "superseded");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
controllers.set(controllerId, {
|
|
754
|
+
id: controllerId,
|
|
755
|
+
ws,
|
|
756
|
+
name: ctrlName,
|
|
757
|
+
pid: typeof msg.payload?.pid === "number" ? msg.payload.pid : void 0,
|
|
758
|
+
instanceId,
|
|
759
|
+
lastSeenAt: Date.now(),
|
|
760
|
+
takeoverNotices: /* @__PURE__ */ new Map()
|
|
761
|
+
});
|
|
762
|
+
rlog.info(
|
|
763
|
+
`controller connected id=${controllerId.slice(0, 8)} name="${ctrlName}" (controllers=${controllers.size})`
|
|
764
|
+
);
|
|
765
|
+
try {
|
|
766
|
+
ws.send(
|
|
767
|
+
JSON.stringify({
|
|
768
|
+
type: "control.welcome",
|
|
769
|
+
payload: {
|
|
770
|
+
ctrlId: controllerId,
|
|
771
|
+
// Disambiguated, so this controller reports the same name to its
|
|
772
|
+
// user that every peer and browser popup sees for it (P0-E).
|
|
773
|
+
name: nameOf(controllerId),
|
|
774
|
+
relayVersion: version2,
|
|
775
|
+
relayHost: host,
|
|
776
|
+
browsers: browsers.info(),
|
|
777
|
+
controllers: peerList().map((p) => ({
|
|
778
|
+
...p,
|
|
779
|
+
self: p.id === controllerId
|
|
780
|
+
}))
|
|
781
|
+
}
|
|
782
|
+
})
|
|
783
|
+
);
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
broadcastRoster();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
rlog.info(`socket ignored unknown first frame type=${msg.type}`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (role === "browser" && msg.type === "identify" && browserId) {
|
|
793
|
+
const p = msg.payload ?? {};
|
|
794
|
+
browsers.updateMeta(browserId, {
|
|
795
|
+
browser: str(p.browser),
|
|
796
|
+
browserVersion: str(p.browserVersion),
|
|
797
|
+
label: str(p.label),
|
|
798
|
+
instanceId: str(p.instanceId),
|
|
799
|
+
tabId: typeof p.tabId === "number" ? p.tabId : void 0,
|
|
800
|
+
tabUrl: str(p.tabUrl),
|
|
801
|
+
tabTitle: str(p.tabTitle)
|
|
802
|
+
});
|
|
803
|
+
broadcastBrowsers();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (role === "controller" && isControlSend(msg)) {
|
|
807
|
+
void handleControlSend(msg);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (role === "controller" && isControlClaim(msg)) {
|
|
811
|
+
handleControlClaim(msg);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (role === "controller" && isControlRelease(msg)) {
|
|
815
|
+
handleControlRelease(msg);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
const onClose = () => {
|
|
820
|
+
clearTimeout(roleTimer);
|
|
821
|
+
clearInterval(hb);
|
|
822
|
+
ws.off("pong", onPong);
|
|
823
|
+
ws.off("message", onMessage);
|
|
824
|
+
if (role === "browser" && browserId) {
|
|
825
|
+
browsers.remove(browserId);
|
|
826
|
+
rlog.info(`browser removed id=${browserId.slice(0, 8)} (browsers=${browsers.size()})`);
|
|
827
|
+
broadcastBrowsers();
|
|
828
|
+
} else if (role === "controller" && controllerId) {
|
|
829
|
+
const freed = browsers.releaseByController(controllerId);
|
|
830
|
+
controllers.delete(controllerId);
|
|
831
|
+
rlog.info(
|
|
832
|
+
`controller removed id=${controllerId.slice(0, 8)} freed=${freed.length} (controllers=${controllers.size})`
|
|
833
|
+
);
|
|
834
|
+
broadcastRoster();
|
|
835
|
+
}
|
|
836
|
+
armIdleExit();
|
|
837
|
+
};
|
|
838
|
+
ws.on("message", onMessage);
|
|
839
|
+
ws.once("close", onClose);
|
|
840
|
+
ws.once("error", (err) => {
|
|
841
|
+
if (/max payload/i.test(String(err))) {
|
|
842
|
+
rlog.error(
|
|
843
|
+
`socket dropped: a peer sent a frame larger than the ${process.env.AUTOMATE_BROWSER_WS_MAX_PAYLOAD_BYTES ?? "64 MiB"} limit (${String(err)}). The peer will reconnect, but the request it was answering is lost and will surface as a timeout. Raise AUTOMATE_BROWSER_WS_MAX_PAYLOAD_BYTES if this is a legitimate reply.`
|
|
844
|
+
);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
rlog.info("socket error", String(err));
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
rlog.info(`relay listening on ws://${host}:${port}`);
|
|
851
|
+
return {
|
|
852
|
+
port,
|
|
853
|
+
close: () => {
|
|
854
|
+
clearInterval(reaper);
|
|
855
|
+
try {
|
|
856
|
+
wss.close();
|
|
857
|
+
} catch {
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/relay/index.ts
|
|
864
|
+
var version = process.env.AUTOMATE_BROWSER_RELAY_VERSION ?? mcpConfig.server.version;
|
|
865
|
+
function resolveHost() {
|
|
866
|
+
const raw = process.env.AUTOMATE_BROWSER_RELAY_HOST?.trim();
|
|
867
|
+
if (!raw) return "127.0.0.1";
|
|
868
|
+
if (isLoopbackHost(raw)) return raw;
|
|
869
|
+
if (!getAuthToken()) {
|
|
870
|
+
rlog.error(
|
|
871
|
+
`AUTOMATE_BROWSER_RELAY_HOST=${raw} would make this relay reachable from other machines, but AUTOMATE_BROWSER_TOKEN is not set. Refusing to listen without a shared secret \u2014 set AUTOMATE_BROWSER_TOKEN to the same value here and in the browser extension's popup, or unset AUTOMATE_BROWSER_RELAY_HOST to stay on loopback.`
|
|
872
|
+
);
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
rlog.warn(
|
|
876
|
+
`binding ${raw} \u2014 this relay is reachable from other machines on the network (token required, and every peer must prove it).`
|
|
877
|
+
);
|
|
878
|
+
return raw;
|
|
879
|
+
}
|
|
880
|
+
async function main() {
|
|
881
|
+
const host = resolveHost();
|
|
882
|
+
if (host === null) {
|
|
883
|
+
process.exit(1);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
let bound;
|
|
887
|
+
try {
|
|
888
|
+
bound = await createWebSocketServer(mcpConfig.wsPortRange, host);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
rlog.error("failed to bind any port in range; exiting", String(e));
|
|
891
|
+
process.exit(0);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const { wss, port } = bound;
|
|
895
|
+
const [start, end] = mcpConfig.wsPortRange;
|
|
896
|
+
for (let p = start; p < port; p++) {
|
|
897
|
+
const hello = await probePort(p, 400);
|
|
898
|
+
if (hello && hello.role === "relay") {
|
|
899
|
+
rlog.info(`another relay already on :${p}; exiting (this would be :${port})`);
|
|
900
|
+
try {
|
|
901
|
+
wss.close();
|
|
902
|
+
} catch {
|
|
903
|
+
}
|
|
904
|
+
process.exit(0);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
for (let p = start; p <= end; p++) {
|
|
909
|
+
if (p === port) continue;
|
|
910
|
+
const hello = await probePort(p, 400);
|
|
911
|
+
if (hello && hello.server === "automate-browser" && hello.role !== "relay") {
|
|
912
|
+
rlog.warn(
|
|
913
|
+
`legacy AutomateBrowser server detected on :${p} \u2014 it may capture browsers; remove the old npx config and restart that IDE`
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
startRelay(wss, port, version, host);
|
|
918
|
+
}
|
|
919
|
+
void main();
|