machine-bridge-mcp 1.2.7 → 1.2.9
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/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +37 -19
- package/README.md +120 -410
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +13 -4
- package/docs/AUDIT.md +22 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +78 -33
- package/docs/TESTING.md +14 -7
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +18 -6
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +49 -0
- package/scripts/github-release.mjs +15 -8
- package/scripts/local-release-acceptance.mjs +186 -0
- package/scripts/release-acceptance.mjs +181 -0
- package/scripts/release-impact-check.mjs +19 -3
- package/scripts/release-state.mjs +1 -1
- package/scripts/run-checks.mjs +29 -0
- package/scripts/start-release-candidate.mjs +113 -0
- package/src/local/agent-context-projection.mjs +158 -0
- package/src/local/agent-context.mjs +23 -332
- package/src/local/agent-skill-discovery.mjs +230 -0
- package/src/local/agent-text-file.mjs +41 -0
- package/src/local/browser-bridge-http.mjs +48 -0
- package/src/local/browser-bridge.mjs +48 -222
- package/src/local/browser-broker-routes.mjs +136 -0
- package/src/local/browser-broker-server.mjs +59 -0
- package/src/local/browser-request-registry.mjs +67 -0
- package/src/local/managed-job-lock.mjs +99 -0
- package/src/local/managed-job-projection.mjs +68 -0
- package/src/local/managed-job-runner.mjs +73 -0
- package/src/local/managed-job-storage.mjs +93 -0
- package/src/local/managed-jobs.mjs +12 -297
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +73 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +22 -204
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/index.ts +9 -118
- package/src/worker/mcp-jsonrpc.ts +94 -0
- package/src/worker/oauth-authorization-page.ts +70 -0
- package/src/worker/oauth-controller.ts +9 -58
- package/src/worker/websocket-protocol.ts +24 -0
- package/tsconfig.local.json +6 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
|
|
2
|
+
import { isAllowedLoopbackHost, pairingHtml, securityHeaders, sendJson } from "./browser-pairing-store.mjs";
|
|
3
|
+
|
|
4
|
+
export function handleBrowserBridgeHttp(request, response, {
|
|
5
|
+
port,
|
|
6
|
+
token,
|
|
7
|
+
extensionConnected,
|
|
8
|
+
extensionStatusInfo,
|
|
9
|
+
extensionReloadRequired,
|
|
10
|
+
}) {
|
|
11
|
+
const host = String(request.headers.host || "");
|
|
12
|
+
if (!isAllowedLoopbackHost(host, port)) {
|
|
13
|
+
response.writeHead(403, securityHeaders("text/plain; charset=utf-8"));
|
|
14
|
+
response.end("Forbidden\n");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const url = new URL(request.url || "/", `http://${host}`);
|
|
18
|
+
if (request.method !== "GET") {
|
|
19
|
+
response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (url.pathname === "/healthz") {
|
|
23
|
+
const extension = extensionStatusInfo();
|
|
24
|
+
sendJson(response, {
|
|
25
|
+
ok: true,
|
|
26
|
+
connected: extensionConnected(),
|
|
27
|
+
broker: "machine-bridge-browser",
|
|
28
|
+
expected_extension_version: EXPECTED_EXTENSION_VERSION,
|
|
29
|
+
extension_protocol: extension?.protocol || null,
|
|
30
|
+
extension_version: extension?.version || "",
|
|
31
|
+
extension_capabilities: extension?.capabilities || [],
|
|
32
|
+
extension_reload_required: extensionReloadRequired(),
|
|
33
|
+
controls_existing_profile: true,
|
|
34
|
+
controls_extension_profile: true,
|
|
35
|
+
machine_bridge_launches_browser: false,
|
|
36
|
+
profile_identity_verifiable: false,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (url.pathname === "/pair") {
|
|
41
|
+
const html = pairingHtml(port, token);
|
|
42
|
+
response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
|
|
43
|
+
response.end(html);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
|
|
47
|
+
response.end("Not found\n");
|
|
48
|
+
}
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
1
|
import { resolve } from "node:path";
|
|
4
|
-
import { WebSocket
|
|
2
|
+
import { WebSocket } from "ws";
|
|
5
3
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
6
4
|
import { assertStateMaintenanceAvailable, packageRoot } from "./state.mjs";
|
|
7
5
|
import {
|
|
8
6
|
BROWSER_EXTENSION_PROTOCOL, EXPECTED_EXTENSION_VERSION, MAX_BROWSER_MESSAGE_BYTES,
|
|
9
7
|
closeProtocolSocket, normalizeCompatibleExtensionInfo, parseBrowserSocketMessage, parseExtensionHello, safeSocketSend,
|
|
10
8
|
} from "./browser-extension-protocol.mjs";
|
|
11
|
-
import {
|
|
12
|
-
isAllowedExtensionOrigin, isAllowedLoopbackHost, loadOrCreatePairing,
|
|
13
|
-
pairingHtml, savePairing, securityHeaders, sendJson,
|
|
14
|
-
} from "./browser-pairing-store.mjs";
|
|
15
|
-
import { clampInt } from "./browser-command.mjs";
|
|
9
|
+
import { loadOrCreatePairing, savePairing } from "./browser-pairing-store.mjs";
|
|
16
10
|
import { BrowserOperationService } from "./browser-operation-service.mjs";
|
|
17
11
|
import { classifyOperationalError } from "./log.mjs";
|
|
12
|
+
import { BrowserRequestRegistry } from "./browser-request-registry.mjs";
|
|
13
|
+
import { handleBrowserBridgeHttp } from "./browser-bridge-http.mjs";
|
|
14
|
+
import { BrowserBrokerRoutes } from "./browser-broker-routes.mjs";
|
|
15
|
+
import { startBrowserBrokerServer } from "./browser-broker-server.mjs";
|
|
18
16
|
|
|
19
17
|
const MAX_PORT_ATTEMPTS = 10;
|
|
20
18
|
const MAX_PENDING = 32;
|
|
@@ -35,8 +33,6 @@ export class BrowserBridgeManager {
|
|
|
35
33
|
this.socket = null;
|
|
36
34
|
this.pendingExtensionSocket = null;
|
|
37
35
|
this.upstream = null;
|
|
38
|
-
this.runtimeClients = new Set();
|
|
39
|
-
this.proxyRoutes = new Map();
|
|
40
36
|
this.proxyExtensionConnected = false;
|
|
41
37
|
this.extensionInfo = null;
|
|
42
38
|
this.proxyExtensionInfo = null;
|
|
@@ -44,7 +40,18 @@ export class BrowserBridgeManager {
|
|
|
44
40
|
this.extensionReloadRequiredFlag = false;
|
|
45
41
|
this.recoveryTimer = null;
|
|
46
42
|
this.stopping = false;
|
|
47
|
-
this.
|
|
43
|
+
this.requestRegistry = new BrowserRequestRegistry({ maximum: MAX_PENDING });
|
|
44
|
+
this.pending = this.requestRegistry.pending;
|
|
45
|
+
this.brokerRoutes = new BrowserBrokerRoutes({
|
|
46
|
+
maximum: MAX_PENDING * 4,
|
|
47
|
+
getExtensionSocket: () => this.socket,
|
|
48
|
+
extensionConnected: () => this.extensionConnected(),
|
|
49
|
+
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
50
|
+
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
51
|
+
});
|
|
52
|
+
// Public aliases remain for diagnostics and compatibility with existing behavioral tests.
|
|
53
|
+
this.runtimeClients = this.brokerRoutes.clients;
|
|
54
|
+
this.proxyRoutes = this.brokerRoutes.routes;
|
|
48
55
|
this.startPromise = null;
|
|
49
56
|
this.startGeneration = 0;
|
|
50
57
|
this.port = 0;
|
|
@@ -95,30 +102,15 @@ export class BrowserBridgeManager {
|
|
|
95
102
|
await this.ensureStarted(context);
|
|
96
103
|
this.throwIfCancelled(context);
|
|
97
104
|
const transport = this.server ? this.socket : this.upstream;
|
|
98
|
-
|
|
99
|
-
if (!transport || transport.readyState !== 1 || !extensionConnected) {
|
|
105
|
+
if (!transport || transport.readyState !== 1 || !this.extensionConnected()) {
|
|
100
106
|
throw new Error("browser extension is not connected; call pair_browser_extension after loading the packaged extension");
|
|
101
107
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
try { transport.send(JSON.stringify({ type: "cancel", id })); } catch {}
|
|
109
|
-
rejectPromise(new Error(`browser request timed out after ${timeoutSeconds}s`));
|
|
110
|
-
}, timeoutMs);
|
|
111
|
-
timeout.unref?.();
|
|
112
|
-
this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timeout, callId: context.callId || "" });
|
|
113
|
-
try {
|
|
114
|
-
const message = JSON.stringify({ type: "request", id, method, params, timeout_ms: timeoutMs });
|
|
115
|
-
if (Buffer.byteLength(message) > MAX_BROWSER_MESSAGE_BYTES) throw new Error("browser request exceeds maximum message size");
|
|
116
|
-
transport.send(message);
|
|
117
|
-
} catch (error) {
|
|
118
|
-
clearTimeout(timeout);
|
|
119
|
-
this.pending.delete(id);
|
|
120
|
-
rejectPromise(error);
|
|
121
|
-
}
|
|
108
|
+
return this.requestRegistry.request({
|
|
109
|
+
transport,
|
|
110
|
+
method,
|
|
111
|
+
params,
|
|
112
|
+
timeoutSeconds,
|
|
113
|
+
callId: context.callId || "",
|
|
122
114
|
});
|
|
123
115
|
}
|
|
124
116
|
|
|
@@ -182,43 +174,12 @@ export class BrowserBridgeManager {
|
|
|
182
174
|
|
|
183
175
|
async listen(port) {
|
|
184
176
|
this.port = port;
|
|
185
|
-
const server
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
192
|
-
socket.destroy();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const url = new URL(request.url || "/", `http://${host}`);
|
|
196
|
-
const protocol = String(request.headers["sec-websocket-protocol"] || "");
|
|
197
|
-
const origin = String(request.headers.origin || "");
|
|
198
|
-
let role = "";
|
|
199
|
-
if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && isAllowedExtensionOrigin(origin)) role = "extension";
|
|
200
|
-
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
|
|
201
|
-
if (!role) {
|
|
202
|
-
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
203
|
-
socket.destroy();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
207
|
-
ws.bridgeRole = role;
|
|
208
|
-
wss.emit("connection", ws, request);
|
|
209
|
-
});
|
|
210
|
-
} catch {
|
|
211
|
-
socket.destroy();
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
wss.on("connection", (ws) => this.acceptSocket(ws, ws.bridgeRole));
|
|
215
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
216
|
-
const onError = (error) => { cleanup(); try { wss.close(); } catch {} try { server.close(); } catch {} rejectPromise(error); };
|
|
217
|
-
const onListening = () => { cleanup(); resolvePromise(); };
|
|
218
|
-
const cleanup = () => { server.off("error", onError); server.off("listening", onListening); };
|
|
219
|
-
server.once("error", onError);
|
|
220
|
-
server.once("listening", onListening);
|
|
221
|
-
server.listen(port, "127.0.0.1");
|
|
177
|
+
const { server, wss } = await startBrowserBrokerServer({
|
|
178
|
+
port,
|
|
179
|
+
token: this.token,
|
|
180
|
+
maxPayload: MAX_BROWSER_MESSAGE_BYTES,
|
|
181
|
+
onHttp: (request, response) => this.handleHttp(request, response),
|
|
182
|
+
onSocket: (socket, role) => this.acceptSocket(socket, role),
|
|
222
183
|
});
|
|
223
184
|
this.server = server;
|
|
224
185
|
this.wss = wss;
|
|
@@ -284,24 +245,7 @@ export class BrowserBridgeManager {
|
|
|
284
245
|
|
|
285
246
|
acceptSocket(ws, role) {
|
|
286
247
|
if (role === "runtime") {
|
|
287
|
-
this.
|
|
288
|
-
ws.on("message", (data) => this.handleRuntimeClientMessage(ws, data));
|
|
289
|
-
ws.on("close", () => {
|
|
290
|
-
this.runtimeClients.delete(ws);
|
|
291
|
-
for (const [id, route] of this.proxyRoutes) {
|
|
292
|
-
if (route.socket !== ws) continue;
|
|
293
|
-
clearTimeout(route.timeout);
|
|
294
|
-
this.proxyRoutes.delete(id);
|
|
295
|
-
try { this.socket?.send(JSON.stringify({ type: "cancel", id })); } catch {}
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
ws.on("error", () => {});
|
|
299
|
-
safeSocketSend(ws, {
|
|
300
|
-
type: "hello", role: "runtime", protocol: 1,
|
|
301
|
-
extension_connected: this.extensionConnected(),
|
|
302
|
-
extension_info: this.extensionStatusInfo(),
|
|
303
|
-
extension_reload_required: this.extensionReloadRequired(),
|
|
304
|
-
});
|
|
248
|
+
this.brokerRoutes.acceptClient(ws);
|
|
305
249
|
return;
|
|
306
250
|
}
|
|
307
251
|
if (this.pendingExtensionSocket && this.pendingExtensionSocket.readyState === 1) {
|
|
@@ -386,73 +330,12 @@ export class BrowserBridgeManager {
|
|
|
386
330
|
closeProtocolSocket(socket, 1002, "invalid extension protocol message");
|
|
387
331
|
return;
|
|
388
332
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
clearTimeout(pending.timeout);
|
|
392
|
-
this.pending.delete(message.id);
|
|
393
|
-
if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
|
|
394
|
-
else pending.resolve(message.result);
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
const route = this.proxyRoutes.get(message.id);
|
|
398
|
-
if (!route) return;
|
|
399
|
-
clearTimeout(route.timeout);
|
|
400
|
-
this.proxyRoutes.delete(message.id);
|
|
401
|
-
try { route.socket.send(JSON.stringify({ ...message, id: route.id })); } catch {}
|
|
333
|
+
if (this.requestRegistry.settle(message)) return;
|
|
334
|
+
this.brokerRoutes.settleExtensionResponse(message);
|
|
402
335
|
}
|
|
403
336
|
|
|
404
337
|
handleRuntimeClientMessage(socket, data) {
|
|
405
|
-
|
|
406
|
-
if (!parsed.ok) {
|
|
407
|
-
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const message = parsed.message;
|
|
411
|
-
if (message.type === "ping") return;
|
|
412
|
-
if (message.type === "cancel" && typeof message.id === "string") {
|
|
413
|
-
for (const [routedId, route] of this.proxyRoutes) {
|
|
414
|
-
if (route.socket !== socket || route.id !== message.id) continue;
|
|
415
|
-
clearTimeout(route.timeout);
|
|
416
|
-
this.proxyRoutes.delete(routedId);
|
|
417
|
-
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
418
|
-
}
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
|
|
422
|
-
closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (!this.extensionConnected()) {
|
|
426
|
-
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
if (this.proxyRoutes.size >= MAX_PENDING * 4) {
|
|
430
|
-
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker is busy" });
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
const routedId = `proxy_${randomBytes(18).toString("base64url")}`;
|
|
434
|
-
let timeoutMs;
|
|
435
|
-
try { timeoutMs = clampInt(message.timeout_ms, 30_000, 1_000, 185_000); }
|
|
436
|
-
catch {
|
|
437
|
-
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "invalid browser request timeout" });
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
const timeout = setTimeout(() => {
|
|
441
|
-
const route = this.proxyRoutes.get(routedId);
|
|
442
|
-
if (!route) return;
|
|
443
|
-
this.proxyRoutes.delete(routedId);
|
|
444
|
-
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
|
|
445
|
-
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
446
|
-
}, timeoutMs);
|
|
447
|
-
timeout.unref?.();
|
|
448
|
-
this.proxyRoutes.set(routedId, { socket, id: message.id, timeout });
|
|
449
|
-
try {
|
|
450
|
-
this.socket.send(JSON.stringify({ ...message, id: routedId, timeout_ms: timeoutMs }));
|
|
451
|
-
} catch {
|
|
452
|
-
clearTimeout(timeout);
|
|
453
|
-
this.proxyRoutes.delete(routedId);
|
|
454
|
-
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension send failed" });
|
|
455
|
-
}
|
|
338
|
+
return this.brokerRoutes.handleClientMessage(socket, data);
|
|
456
339
|
}
|
|
457
340
|
|
|
458
341
|
handleUpstreamMessage(message) {
|
|
@@ -465,22 +348,12 @@ export class BrowserBridgeManager {
|
|
|
465
348
|
return true;
|
|
466
349
|
}
|
|
467
350
|
if (message.type !== "response" || typeof message.id !== "string") return false;
|
|
468
|
-
|
|
469
|
-
if (!pending) return true;
|
|
470
|
-
clearTimeout(pending.timeout);
|
|
471
|
-
this.pending.delete(message.id);
|
|
472
|
-
if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
|
|
473
|
-
else pending.resolve(message.result);
|
|
351
|
+
this.requestRegistry.settle(message);
|
|
474
352
|
return true;
|
|
475
353
|
}
|
|
476
354
|
|
|
477
355
|
broadcastRuntimeStatus(connected) {
|
|
478
|
-
|
|
479
|
-
type: "status", extension_connected: connected,
|
|
480
|
-
extension_info: connected ? this.extensionStatusInfo() : null,
|
|
481
|
-
extension_reload_required: this.extensionReloadRequired(),
|
|
482
|
-
};
|
|
483
|
-
for (const client of this.runtimeClients) safeSocketSend(client, message);
|
|
356
|
+
this.brokerRoutes.broadcastStatus(connected);
|
|
484
357
|
}
|
|
485
358
|
|
|
486
359
|
scheduleBrokerRecovery() {
|
|
@@ -497,71 +370,26 @@ export class BrowserBridgeManager {
|
|
|
497
370
|
}
|
|
498
371
|
|
|
499
372
|
handleHttp(request, response) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if (request.method !== "GET") {
|
|
508
|
-
response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
if (url.pathname === "/healthz") {
|
|
512
|
-
const extension = this.extensionStatusInfo();
|
|
513
|
-
sendJson(response, {
|
|
514
|
-
ok: true,
|
|
515
|
-
connected: this.extensionConnected(),
|
|
516
|
-
broker: "machine-bridge-browser",
|
|
517
|
-
expected_extension_version: EXPECTED_EXTENSION_VERSION,
|
|
518
|
-
extension_protocol: extension?.protocol || null,
|
|
519
|
-
extension_version: extension?.version || "",
|
|
520
|
-
extension_capabilities: extension?.capabilities || [],
|
|
521
|
-
extension_reload_required: this.extensionReloadRequired(),
|
|
522
|
-
controls_existing_profile: true,
|
|
523
|
-
controls_extension_profile: true,
|
|
524
|
-
machine_bridge_launches_browser: false,
|
|
525
|
-
profile_identity_verifiable: false,
|
|
526
|
-
});
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
if (url.pathname === "/pair") {
|
|
530
|
-
const html = pairingHtml(this.port, this.token);
|
|
531
|
-
response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
|
|
532
|
-
response.end(html);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
|
|
536
|
-
response.end("Not found\n");
|
|
373
|
+
return handleBrowserBridgeHttp(request, response, {
|
|
374
|
+
port: this.port,
|
|
375
|
+
token: this.token,
|
|
376
|
+
extensionConnected: () => this.extensionConnected(),
|
|
377
|
+
extensionStatusInfo: () => this.extensionStatusInfo(),
|
|
378
|
+
extensionReloadRequired: () => this.extensionReloadRequired(),
|
|
379
|
+
});
|
|
537
380
|
}
|
|
538
381
|
|
|
539
382
|
cancelCall(callId) {
|
|
540
|
-
if (!callId) return;
|
|
541
383
|
const transport = this.server ? this.socket : this.upstream;
|
|
542
|
-
|
|
543
|
-
if (pending.callId !== callId) continue;
|
|
544
|
-
clearTimeout(pending.timeout);
|
|
545
|
-
this.pending.delete(id);
|
|
546
|
-
try { transport?.send(JSON.stringify({ type: "cancel", id })); } catch {}
|
|
547
|
-
pending.reject(new Error("browser request cancelled; a user-visible action may already have completed"));
|
|
548
|
-
}
|
|
384
|
+
this.requestRegistry.cancelCall(callId, transport);
|
|
549
385
|
}
|
|
550
386
|
|
|
551
387
|
rejectPending(message) {
|
|
552
|
-
|
|
553
|
-
clearTimeout(pending.timeout);
|
|
554
|
-
pending.reject(new Error(message));
|
|
555
|
-
}
|
|
556
|
-
this.pending.clear();
|
|
388
|
+
this.requestRegistry.rejectAll(message);
|
|
557
389
|
}
|
|
558
390
|
|
|
559
391
|
rejectProxyRoutes(message) {
|
|
560
|
-
|
|
561
|
-
clearTimeout(route.timeout);
|
|
562
|
-
safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
|
|
563
|
-
this.proxyRoutes.delete(id);
|
|
564
|
-
}
|
|
392
|
+
this.brokerRoutes.rejectAll(message);
|
|
565
393
|
}
|
|
566
394
|
|
|
567
395
|
stop() {
|
|
@@ -574,11 +402,10 @@ export class BrowserBridgeManager {
|
|
|
574
402
|
|
|
575
403
|
closeBrokerTransports(message) {
|
|
576
404
|
this.rejectPending(message);
|
|
577
|
-
this.rejectProxyRoutes(message);
|
|
578
405
|
try { this.upstream?.close(1001, "runtime stopped"); } catch {}
|
|
579
406
|
try { this.socket?.close(1001, "runtime stopped"); } catch {}
|
|
580
407
|
try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
|
|
581
|
-
|
|
408
|
+
this.brokerRoutes.close(message);
|
|
582
409
|
this.upstream = null;
|
|
583
410
|
this.socket = null;
|
|
584
411
|
this.pendingExtensionSocket = null;
|
|
@@ -587,7 +414,6 @@ export class BrowserBridgeManager {
|
|
|
587
414
|
this.proxyExtensionInfo = null;
|
|
588
415
|
this.proxyExtensionReloadRequired = false;
|
|
589
416
|
this.extensionReloadRequiredFlag = false;
|
|
590
|
-
this.runtimeClients.clear();
|
|
591
417
|
try { this.wss?.close(); } catch {}
|
|
592
418
|
try { this.server?.close(); } catch {}
|
|
593
419
|
this.wss = null;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { clampInt } from "./browser-command.mjs";
|
|
3
|
+
import { closeProtocolSocket, parseBrowserSocketMessage, safeSocketSend } from "./browser-extension-protocol.mjs";
|
|
4
|
+
|
|
5
|
+
export class BrowserBrokerRoutes {
|
|
6
|
+
constructor({ maximum, getExtensionSocket, extensionConnected, extensionStatusInfo, extensionReloadRequired }) {
|
|
7
|
+
this.maximum = maximum;
|
|
8
|
+
this.getExtensionSocket = getExtensionSocket;
|
|
9
|
+
this.extensionConnected = extensionConnected;
|
|
10
|
+
this.extensionStatusInfo = extensionStatusInfo;
|
|
11
|
+
this.extensionReloadRequired = extensionReloadRequired;
|
|
12
|
+
this.clients = new Set();
|
|
13
|
+
this.routes = new Map();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
acceptClient(socket) {
|
|
17
|
+
this.clients.add(socket);
|
|
18
|
+
socket.on("message", (data) => this.handleClientMessage(socket, data));
|
|
19
|
+
socket.on("close", () => this.removeClient(socket));
|
|
20
|
+
socket.on("error", () => {});
|
|
21
|
+
safeSocketSend(socket, {
|
|
22
|
+
type: "hello",
|
|
23
|
+
role: "runtime",
|
|
24
|
+
protocol: 1,
|
|
25
|
+
extension_connected: this.extensionConnected(),
|
|
26
|
+
extension_info: this.extensionStatusInfo(),
|
|
27
|
+
extension_reload_required: this.extensionReloadRequired(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
removeClient(socket) {
|
|
32
|
+
this.clients.delete(socket);
|
|
33
|
+
for (const [id, route] of this.routes) {
|
|
34
|
+
if (route.socket !== socket) continue;
|
|
35
|
+
clearTimeout(route.timeout);
|
|
36
|
+
this.routes.delete(id);
|
|
37
|
+
this.sendExtension({ type: "cancel", id });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
handleClientMessage(socket, data) {
|
|
42
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
43
|
+
if (!parsed.ok) {
|
|
44
|
+
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const message = parsed.message;
|
|
48
|
+
if (message.type === "ping") return;
|
|
49
|
+
if (message.type === "cancel" && typeof message.id === "string") {
|
|
50
|
+
this.cancelClientRequest(socket, message.id);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
|
|
54
|
+
closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!this.extensionConnected()) {
|
|
58
|
+
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.routes.size >= this.maximum) {
|
|
62
|
+
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker is busy" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const routedId = `proxy_${randomBytes(18).toString("base64url")}`;
|
|
66
|
+
let timeoutMs;
|
|
67
|
+
try { timeoutMs = clampInt(message.timeout_ms, 30_000, 1_000, 185_000); }
|
|
68
|
+
catch {
|
|
69
|
+
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "invalid browser request timeout" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const timeout = setTimeout(() => {
|
|
73
|
+
const route = this.routes.get(routedId);
|
|
74
|
+
if (!route) return;
|
|
75
|
+
this.routes.delete(routedId);
|
|
76
|
+
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
|
|
77
|
+
this.sendExtension({ type: "cancel", id: routedId });
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
timeout.unref?.();
|
|
80
|
+
this.routes.set(routedId, { socket, id: message.id, timeout });
|
|
81
|
+
if (this.sendExtension({ ...message, id: routedId, timeout_ms: timeoutMs })) return;
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
this.routes.delete(routedId);
|
|
84
|
+
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension send failed" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cancelClientRequest(socket, requestId) {
|
|
88
|
+
for (const [routedId, route] of this.routes) {
|
|
89
|
+
if (route.socket !== socket || route.id !== requestId) continue;
|
|
90
|
+
clearTimeout(route.timeout);
|
|
91
|
+
this.routes.delete(routedId);
|
|
92
|
+
this.sendExtension({ type: "cancel", id: routedId });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
settleExtensionResponse(message) {
|
|
97
|
+
const route = this.routes.get(message.id);
|
|
98
|
+
if (!route) return false;
|
|
99
|
+
clearTimeout(route.timeout);
|
|
100
|
+
this.routes.delete(message.id);
|
|
101
|
+
safeSocketSend(route.socket, { ...message, id: route.id });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
broadcastStatus(connected) {
|
|
106
|
+
const message = {
|
|
107
|
+
type: "status",
|
|
108
|
+
extension_connected: connected,
|
|
109
|
+
extension_info: connected ? this.extensionStatusInfo() : null,
|
|
110
|
+
extension_reload_required: this.extensionReloadRequired(),
|
|
111
|
+
};
|
|
112
|
+
for (const client of this.clients) safeSocketSend(client, message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
rejectAll(message) {
|
|
116
|
+
for (const [id, route] of this.routes) {
|
|
117
|
+
clearTimeout(route.timeout);
|
|
118
|
+
safeSocketSend(route.socket, { type: "response", id: route.id, ok: false, error: message });
|
|
119
|
+
this.routes.delete(id);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close(message) {
|
|
124
|
+
this.rejectAll(message);
|
|
125
|
+
for (const client of this.clients) {
|
|
126
|
+
try { client.close(1001, "runtime stopped"); } catch {}
|
|
127
|
+
}
|
|
128
|
+
this.clients.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
sendExtension(message) {
|
|
132
|
+
const socket = this.getExtensionSocket();
|
|
133
|
+
if (!socket || socket.readyState !== 1) return false;
|
|
134
|
+
return safeSocketSend(socket, message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { isAllowedExtensionOrigin, isAllowedLoopbackHost } from "./browser-pairing-store.mjs";
|
|
4
|
+
|
|
5
|
+
export async function startBrowserBrokerServer({ port, token, maxPayload, onHttp, onSocket }) {
|
|
6
|
+
const server = createServer(onHttp);
|
|
7
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload });
|
|
8
|
+
server.on("upgrade", (request, socket, head) => {
|
|
9
|
+
try {
|
|
10
|
+
const host = String(request.headers.host || "");
|
|
11
|
+
if (!isAllowedLoopbackHost(host, port)) {
|
|
12
|
+
rejectUpgrade(socket, "403 Forbidden");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(request.url || "/", `http://${host}`);
|
|
16
|
+
const protocol = String(request.headers["sec-websocket-protocol"] || "");
|
|
17
|
+
const origin = String(request.headers.origin || "");
|
|
18
|
+
let role = "";
|
|
19
|
+
if (url.pathname === "/extension" && protocol === `mbm.${token}` && isAllowedExtensionOrigin(origin)) role = "extension";
|
|
20
|
+
if (url.pathname === "/runtime" && protocol === `mbm-runtime.${token}` && !origin) role = "runtime";
|
|
21
|
+
if (!role) {
|
|
22
|
+
rejectUpgrade(socket, "401 Unauthorized");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
26
|
+
ws.bridgeRole = role;
|
|
27
|
+
wss.emit("connection", ws, request);
|
|
28
|
+
});
|
|
29
|
+
} catch {
|
|
30
|
+
socket.destroy();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
wss.on("connection", (ws) => onSocket(ws, ws.bridgeRole));
|
|
34
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
server.off("error", onError);
|
|
37
|
+
server.off("listening", onListening);
|
|
38
|
+
};
|
|
39
|
+
const onError = (error) => {
|
|
40
|
+
cleanup();
|
|
41
|
+
try { wss.close(); } catch {}
|
|
42
|
+
try { server.close(); } catch {}
|
|
43
|
+
rejectPromise(error);
|
|
44
|
+
};
|
|
45
|
+
const onListening = () => {
|
|
46
|
+
cleanup();
|
|
47
|
+
resolvePromise();
|
|
48
|
+
};
|
|
49
|
+
server.once("error", onError);
|
|
50
|
+
server.once("listening", onListening);
|
|
51
|
+
server.listen(port, "127.0.0.1");
|
|
52
|
+
});
|
|
53
|
+
return { server, wss };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rejectUpgrade(socket, status) {
|
|
57
|
+
socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
|
58
|
+
socket.destroy();
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { MAX_BROWSER_MESSAGE_BYTES } from "./browser-extension-protocol.mjs";
|
|
3
|
+
|
|
4
|
+
export class BrowserRequestRegistry {
|
|
5
|
+
constructor({ maximum = 32 } = {}) {
|
|
6
|
+
this.maximum = maximum;
|
|
7
|
+
this.pending = new Map();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
request({ transport, method, params, timeoutSeconds, callId = "" }) {
|
|
11
|
+
if (this.pending.size >= this.maximum) throw new Error("too many concurrent browser requests");
|
|
12
|
+
const id = `browser_${randomBytes(18).toString("base64url")}`;
|
|
13
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
14
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
15
|
+
const timeout = setTimeout(() => {
|
|
16
|
+
this.pending.delete(id);
|
|
17
|
+
sendCancelQuietly(transport, id);
|
|
18
|
+
rejectPromise(new Error(`browser request timed out after ${timeoutSeconds}s`));
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
timeout.unref?.();
|
|
21
|
+
this.pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timeout, callId });
|
|
22
|
+
try {
|
|
23
|
+
const message = JSON.stringify({ type: "request", id, method, params, timeout_ms: timeoutMs });
|
|
24
|
+
if (Buffer.byteLength(message) > MAX_BROWSER_MESSAGE_BYTES) throw new Error("browser request exceeds maximum message size");
|
|
25
|
+
transport.send(message);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
clearTimeout(timeout);
|
|
28
|
+
this.pending.delete(id);
|
|
29
|
+
rejectPromise(error);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
settle(message) {
|
|
35
|
+
if (message?.type !== "response" || typeof message.id !== "string") return false;
|
|
36
|
+
const pending = this.pending.get(message.id);
|
|
37
|
+
if (!pending) return false;
|
|
38
|
+
clearTimeout(pending.timeout);
|
|
39
|
+
this.pending.delete(message.id);
|
|
40
|
+
if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
|
|
41
|
+
else pending.resolve(message.result);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cancelCall(callId, transport) {
|
|
46
|
+
if (!callId) return;
|
|
47
|
+
for (const [id, pending] of this.pending) {
|
|
48
|
+
if (pending.callId !== callId) continue;
|
|
49
|
+
clearTimeout(pending.timeout);
|
|
50
|
+
this.pending.delete(id);
|
|
51
|
+
sendCancelQuietly(transport, id);
|
|
52
|
+
pending.reject(new Error("browser request cancelled; a user-visible action may already have completed"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
rejectAll(message) {
|
|
57
|
+
for (const pending of this.pending.values()) {
|
|
58
|
+
clearTimeout(pending.timeout);
|
|
59
|
+
pending.reject(new Error(message));
|
|
60
|
+
}
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sendCancelQuietly(transport, id) {
|
|
66
|
+
try { transport?.send(JSON.stringify({ type: "cancel", id })); } catch {}
|
|
67
|
+
}
|