hoverify-mcp 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +269 -2
- package/dist/bridge.js +602 -0
- package/dist/browsers.js +58 -0
- package/dist/index.js +59 -0
- package/dist/key.js +48 -0
- package/dist/protocol.js +13 -0
- package/dist/tools.js +441 -0
- package/package.json +51 -5
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { PROTOCOL, PING_INTERVAL, NOT_CONNECTED, APPROVAL_NEEDED, PEER_REJECTED, PEER_VERSION, EXTENSION_VERSION, BRIDGE_RESET, BROWSER_MOVED, BROWSER_CLAIMED, } from "./protocol.js";
|
|
5
|
+
import * as Browsers from "./browsers.js";
|
|
6
|
+
import { keyId, loadKey, nonce, prove, proves } from "./key.js";
|
|
7
|
+
const EXTENSION_WAIT = 5000;
|
|
8
|
+
const TAKE_OVER = "take over";
|
|
9
|
+
const HANDED_OVER = "handed over";
|
|
10
|
+
// long enough for the newer session to bind the port before the older ones look for a hub again
|
|
11
|
+
const HANDOVER_WAIT = 2000;
|
|
12
|
+
// an extension that's switched on retries every 2 s, or every 30 s after its worker restarts
|
|
13
|
+
const REJECTION_MEMORY = 45_000;
|
|
14
|
+
const REJECTED_RETRY = 2000;
|
|
15
|
+
const IDLE_CHECK = 30_000;
|
|
16
|
+
const EXTENSION_ORIGIN = /^(chrome|moz)-extension:\/\//;
|
|
17
|
+
const ID = /^[\w-]{8,64}$/;
|
|
18
|
+
const validId = (value) => ID.test(String(value)) ? String(value) : "default";
|
|
19
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
20
|
+
const recency = (browser) => browser.focused ? Infinity : browser.focused_at;
|
|
21
|
+
const MAX_CLAIMS = 50;
|
|
22
|
+
const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
|
|
23
|
+
const projectFrom = (value) => value && typeof value === "object" ? Browsers.projectOf(value.path) : null;
|
|
24
|
+
const display = (agent, project) => project ? `${agent} in ${project.name}` : agent;
|
|
25
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
26
|
+
// every agent session runs its own process, but each browser connects once: the first to bind the port relays for the rest
|
|
27
|
+
export class Bridge {
|
|
28
|
+
options;
|
|
29
|
+
role = null;
|
|
30
|
+
server = null;
|
|
31
|
+
browsers = new Map();
|
|
32
|
+
approvals = new Set();
|
|
33
|
+
hub = null;
|
|
34
|
+
peers = new Map();
|
|
35
|
+
pending = new Map();
|
|
36
|
+
routes = new Map();
|
|
37
|
+
// kept by the session, not the hub, so they survive a hub takeover
|
|
38
|
+
pinned = "";
|
|
39
|
+
asked = false;
|
|
40
|
+
session = randomUUID();
|
|
41
|
+
project = Browsers.projectOf(process.cwd());
|
|
42
|
+
seq = 0;
|
|
43
|
+
closed = false;
|
|
44
|
+
dormant = false;
|
|
45
|
+
lastActive = Date.now();
|
|
46
|
+
idleTimer = null;
|
|
47
|
+
extensionOutdatedAt = 0;
|
|
48
|
+
peerProblem = "";
|
|
49
|
+
keyProblem = "";
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
// election can wait indefinitely on a hub that refuses this session, so the MCP side starts without it
|
|
54
|
+
start() {
|
|
55
|
+
try {
|
|
56
|
+
loadKey();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// without a key this session could only block the port for the sessions that have one
|
|
60
|
+
this.keyProblem = error.message;
|
|
61
|
+
return this.options.log(this.keyProblem);
|
|
62
|
+
}
|
|
63
|
+
this.idleTimer = setInterval(() => this.releaseIfIdle(), Math.min(IDLE_CHECK, this.options.releaseAfter / 4));
|
|
64
|
+
this.idleTimer.unref();
|
|
65
|
+
this.join();
|
|
66
|
+
}
|
|
67
|
+
close() {
|
|
68
|
+
this.closed = true;
|
|
69
|
+
if (this.idleTimer)
|
|
70
|
+
clearInterval(this.idleTimer);
|
|
71
|
+
this.hub?.close();
|
|
72
|
+
this.server?.close();
|
|
73
|
+
for (const client of this.server?.clients || [])
|
|
74
|
+
client.terminate();
|
|
75
|
+
}
|
|
76
|
+
get currentRole() {
|
|
77
|
+
return this.role;
|
|
78
|
+
}
|
|
79
|
+
agentChanged() {
|
|
80
|
+
if (this.role === "hub")
|
|
81
|
+
this.pushAgents();
|
|
82
|
+
else if (this.hub?.readyState === WebSocket.OPEN)
|
|
83
|
+
this.hub.send(JSON.stringify({ type: "agent", ...this.me() }));
|
|
84
|
+
}
|
|
85
|
+
setProject(path) {
|
|
86
|
+
this.project = Browsers.projectOf(path);
|
|
87
|
+
this.agentChanged();
|
|
88
|
+
}
|
|
89
|
+
me() {
|
|
90
|
+
return { agent: this.options.agent(), session: this.session, project: this.project, configured: this.options.configured, pinned: this.pinned, asked: this.asked };
|
|
91
|
+
}
|
|
92
|
+
async call(tool, args, timeoutMs) {
|
|
93
|
+
if (this.keyProblem)
|
|
94
|
+
throw new Error(this.keyProblem);
|
|
95
|
+
this.lastActive = Date.now();
|
|
96
|
+
if (this.dormant) {
|
|
97
|
+
this.dormant = false;
|
|
98
|
+
this.join();
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < 100 && !this.role && !this.peerProblem; i++)
|
|
101
|
+
await sleep(20);
|
|
102
|
+
if (!this.role)
|
|
103
|
+
throw new Error(this.peerProblem || NOT_CONNECTED);
|
|
104
|
+
const id = `${this.options.name}:${++this.seq}`;
|
|
105
|
+
const { browser, ...rest } = args;
|
|
106
|
+
const requested = typeof browser === "string" ? browser.trim() : "";
|
|
107
|
+
// held by id, since its tab ids mean nothing in another browser, even one that takes its name
|
|
108
|
+
const named = requested || (this.asked ? this.pinned : "") || this.options.configured;
|
|
109
|
+
const message = {
|
|
110
|
+
type: "call",
|
|
111
|
+
id,
|
|
112
|
+
tool,
|
|
113
|
+
args: rest,
|
|
114
|
+
...(this.pinned ? { pinned: this.pinned } : {}),
|
|
115
|
+
...(named ? { named, named_by: requested || this.asked ? "argument" : "config" } : {}),
|
|
116
|
+
};
|
|
117
|
+
const result = new Promise((resolve, reject) => {
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
this.pending.delete(id);
|
|
120
|
+
reject(new Error(`Hoverify didn't answer within ${Math.round(timeoutMs / 1000)} seconds`));
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
this.pending.set(id, { resolve, reject, timer, asked: !!requested, pinned: this.pinned });
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
if (this.role === "hub")
|
|
126
|
+
await this.forward(message, null, this.me());
|
|
127
|
+
else
|
|
128
|
+
this.hub.send(JSON.stringify(message));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.settle({ type: "result", id, ok: false, error: error.message });
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
settle(message) {
|
|
136
|
+
const pending = this.pending.get(message.id);
|
|
137
|
+
if (!pending)
|
|
138
|
+
return;
|
|
139
|
+
this.pending.delete(message.id);
|
|
140
|
+
this.lastActive = Date.now();
|
|
141
|
+
clearTimeout(pending.timer);
|
|
142
|
+
const claimed = message.picked === "claim" && !!this.pinned && message.browser !== this.pinned;
|
|
143
|
+
const asked = this.asked || pending.asked;
|
|
144
|
+
// a call sent before the pin moved finishes late with parallel tool calls, and mustn't move it back
|
|
145
|
+
const fresh = pending.asked || pending.pinned === this.pinned;
|
|
146
|
+
if (message.browser && fresh && (message.browser !== this.pinned || asked !== this.asked)) {
|
|
147
|
+
this.pinned = message.browser;
|
|
148
|
+
this.asked = asked;
|
|
149
|
+
this.agentChanged();
|
|
150
|
+
}
|
|
151
|
+
const notes = message.picked === "moved" ? [BROWSER_MOVED] : claimed ? [BROWSER_CLAIMED] : [];
|
|
152
|
+
if (message.ok && message.output) {
|
|
153
|
+
if (message.browsers)
|
|
154
|
+
notes.push(Browsers.status(message.picked, this.options.configured, this.project, message.browsers));
|
|
155
|
+
const { output } = message;
|
|
156
|
+
pending.resolve({ ...output, content: [...output.content, ...notes.map(text => ({ type: "text", text }))] });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const error = message.error || "Hoverify couldn't finish the request";
|
|
160
|
+
pending.reject(new Error([error, ...notes].join("\n\n")));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
failAll() {
|
|
164
|
+
for (const id of [...this.pending.keys()])
|
|
165
|
+
this.settle({ type: "result", id, ok: false, error: BRIDGE_RESET });
|
|
166
|
+
}
|
|
167
|
+
everyone() {
|
|
168
|
+
return [this.me(), ...this.peers.values()];
|
|
169
|
+
}
|
|
170
|
+
// names only: a browser still asking to allow the agent hasn't proved anything yet
|
|
171
|
+
agents() {
|
|
172
|
+
return [...new Set(this.everyone().filter(peer => peer.agent).map(peer => display(peer.agent, peer.project)))];
|
|
173
|
+
}
|
|
174
|
+
// the hub owns name matching, so the popups get the ids a session's binding resolves to
|
|
175
|
+
sessions() {
|
|
176
|
+
const open = [...this.browsers.values()];
|
|
177
|
+
return this.everyone().filter(peer => peer.agent).map(peer => {
|
|
178
|
+
const label = this.browsers.get(peer.pinned)?.label || "";
|
|
179
|
+
const bound = peer.asked
|
|
180
|
+
? { by: "argument", name: label, ids: [peer.pinned] }
|
|
181
|
+
: peer.configured ? { by: "config", name: peer.configured, ids: Browsers.matching(open, peer.configured).map(browser => browser.id) } : null;
|
|
182
|
+
return { id: peer.session, agent: peer.agent, project: peer.project, bound, browser: peer.pinned, browser_label: label };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
pushAgents() {
|
|
186
|
+
const agents = this.agents();
|
|
187
|
+
const toBrowsers = JSON.stringify({ type: "agents", agents, sessions: this.sessions() });
|
|
188
|
+
const toApprovals = JSON.stringify({ type: "agents", agents });
|
|
189
|
+
for (const browser of this.browsers.values())
|
|
190
|
+
if (browser.socket.readyState === WebSocket.OPEN)
|
|
191
|
+
browser.socket.send(toBrowsers);
|
|
192
|
+
for (const socket of this.approvals)
|
|
193
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
194
|
+
socket.send(toApprovals);
|
|
195
|
+
}
|
|
196
|
+
// ties go to the browser connected longest, since the map keeps connection order
|
|
197
|
+
latest(browsers) {
|
|
198
|
+
let best;
|
|
199
|
+
for (const browser of browsers)
|
|
200
|
+
if (!best || recency(browser) > recency(best))
|
|
201
|
+
best = browser;
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
accepts(browser, project) {
|
|
205
|
+
return !browser.only || (!!project && browser.claims.includes(project.path));
|
|
206
|
+
}
|
|
207
|
+
list(current = "") {
|
|
208
|
+
return [...this.browsers.values()]
|
|
209
|
+
.sort((a, b) => recency(b) - recency(a))
|
|
210
|
+
.map(browser => ({ id: browser.id, label: browser.label, current: browser.id === current }));
|
|
211
|
+
}
|
|
212
|
+
lastUsed(project, picked) {
|
|
213
|
+
const browser = this.latest([...this.browsers.values()].filter(other => this.accepts(other, project)));
|
|
214
|
+
if (!browser)
|
|
215
|
+
throw new Error(Browsers.allRefused(project, this.list()));
|
|
216
|
+
return { browser, picked };
|
|
217
|
+
}
|
|
218
|
+
pick(message, project) {
|
|
219
|
+
const open = [...this.browsers.values()];
|
|
220
|
+
if (message.named) {
|
|
221
|
+
const matches = Browsers.matching(open, message.named);
|
|
222
|
+
if (matches.length > 1)
|
|
223
|
+
throw new Error(Browsers.ambiguous(message.named, matches));
|
|
224
|
+
if (!matches.length)
|
|
225
|
+
return null;
|
|
226
|
+
if (!this.accepts(matches[0], project))
|
|
227
|
+
throw new Error(Browsers.refused(matches[0].label, project));
|
|
228
|
+
return { browser: matches[0], picked: message.named_by === "config" ? "config" : "argument" };
|
|
229
|
+
}
|
|
230
|
+
const claimed = project ? this.latest(open.filter(browser => browser.claims.includes(project.path))) : undefined;
|
|
231
|
+
if (claimed)
|
|
232
|
+
return { browser: claimed, picked: "claim" };
|
|
233
|
+
if (message.pinned) {
|
|
234
|
+
const pinned = this.browsers.get(message.pinned);
|
|
235
|
+
if (!pinned)
|
|
236
|
+
return null;
|
|
237
|
+
return this.accepts(pinned, project) ? { browser: pinned, picked: "pinned" } : this.lastUsed(project, "moved");
|
|
238
|
+
}
|
|
239
|
+
return open.length ? this.lastUsed(project, "last_used") : null;
|
|
240
|
+
}
|
|
241
|
+
// a named browser is never swapped for another, which has other logins; a session's own browser gets the whole wait before it moves
|
|
242
|
+
async target(message, project) {
|
|
243
|
+
const until = Date.now() + EXTENSION_WAIT;
|
|
244
|
+
for (;;) {
|
|
245
|
+
const found = this.pick(message, project);
|
|
246
|
+
if (found)
|
|
247
|
+
return found;
|
|
248
|
+
if (!this.browsers.size && Date.now() - this.extensionOutdatedAt < REJECTION_MEMORY)
|
|
249
|
+
throw new Error(EXTENSION_VERSION);
|
|
250
|
+
if (Date.now() >= until)
|
|
251
|
+
break;
|
|
252
|
+
await sleep(50);
|
|
253
|
+
}
|
|
254
|
+
if (!this.browsers.size)
|
|
255
|
+
throw new Error(this.approvals.size ? APPROVAL_NEEDED : NOT_CONNECTED);
|
|
256
|
+
if (message.named) {
|
|
257
|
+
// a held browser is known only by an id the user never sees
|
|
258
|
+
const held = message.named_by === "argument" && message.named === message.pinned;
|
|
259
|
+
throw new Error(Browsers.missing(held ? `The browser this session was asked to use (id ${Browsers.shortId(message.named)})` : message.named, this.list()));
|
|
260
|
+
}
|
|
261
|
+
return this.lastUsed(project, "moved");
|
|
262
|
+
}
|
|
263
|
+
// the session comes from the socket the call arrived on, never from the call
|
|
264
|
+
async forward(message, peer, caller) {
|
|
265
|
+
const { pinned, named, named_by, ...call } = message;
|
|
266
|
+
call.session = caller.session;
|
|
267
|
+
const { browser: target, picked } = await this.target(message, caller.project);
|
|
268
|
+
this.routes.set(message.id, { peer, socket: target.socket, picked, status: message.tool === "get_status" });
|
|
269
|
+
target.socket.send(JSON.stringify(call));
|
|
270
|
+
}
|
|
271
|
+
join() {
|
|
272
|
+
this.elect().catch(error => this.options.log(`can't listen on port ${this.options.port}: ${error.message}`));
|
|
273
|
+
}
|
|
274
|
+
releaseIfIdle() {
|
|
275
|
+
if (!this.role || this.pending.size || Date.now() - this.lastActive < this.options.releaseAfter)
|
|
276
|
+
return;
|
|
277
|
+
if (this.role === "hub") {
|
|
278
|
+
if (this.server?.clients.size)
|
|
279
|
+
return;
|
|
280
|
+
this.server?.close();
|
|
281
|
+
this.server = null;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
// an idle peer would otherwise take the port back the moment the hub lets go
|
|
285
|
+
const hub = this.hub;
|
|
286
|
+
this.hub = null;
|
|
287
|
+
hub?.close();
|
|
288
|
+
}
|
|
289
|
+
this.options.log(this.role === "hub" ? "idle, released the port" : "idle, left the hub");
|
|
290
|
+
this.role = null;
|
|
291
|
+
this.dormant = true;
|
|
292
|
+
}
|
|
293
|
+
async elect() {
|
|
294
|
+
// a random delay spreads the survivors so one wins the port cleanly
|
|
295
|
+
await sleep(Math.random() * 150);
|
|
296
|
+
while (!this.closed) {
|
|
297
|
+
if (await this.becomeHub())
|
|
298
|
+
return;
|
|
299
|
+
if (await this.becomePeer())
|
|
300
|
+
return;
|
|
301
|
+
await sleep(this.peerProblem ? REJECTED_RETRY : 100);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
becomeHub() {
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
const server = new WebSocketServer({ host: "127.0.0.1", port: this.options.port, maxPayload: 64 * 1024 * 1024 });
|
|
307
|
+
server.once("listening", () => {
|
|
308
|
+
this.server = server;
|
|
309
|
+
this.role = "hub";
|
|
310
|
+
this.peerProblem = "";
|
|
311
|
+
this.lastActive = Date.now();
|
|
312
|
+
this.options.log("listening for Hoverify");
|
|
313
|
+
resolve(true);
|
|
314
|
+
});
|
|
315
|
+
server.once("error", (error) => {
|
|
316
|
+
if (error.code === "EADDRINUSE")
|
|
317
|
+
resolve(false);
|
|
318
|
+
else
|
|
319
|
+
reject(error);
|
|
320
|
+
});
|
|
321
|
+
server.on("connection", (socket, request) => this.onConnection(socket, request));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
becomePeer() {
|
|
325
|
+
return new Promise(resolve => {
|
|
326
|
+
let key;
|
|
327
|
+
try {
|
|
328
|
+
key = loadKey();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return resolve(false);
|
|
332
|
+
}
|
|
333
|
+
const mine = nonce();
|
|
334
|
+
let proven = false;
|
|
335
|
+
const socket = new WebSocket(`ws://127.0.0.1:${this.options.port}`, { maxPayload: 64 * 1024 * 1024 });
|
|
336
|
+
// a hub that neither welcomes nor refuses is treated as gone
|
|
337
|
+
const unanswered = setTimeout(() => socket.terminate(), 2000);
|
|
338
|
+
// the session's project folder waits for the hub's proof, so whatever squats the port never learns it
|
|
339
|
+
socket.once("open", () => socket.send(JSON.stringify({ type: "hello", role: "peer", protocol: PROTOCOL, nonce: mine })));
|
|
340
|
+
socket.once("error", () => resolve(false));
|
|
341
|
+
socket.on("message", raw => {
|
|
342
|
+
let message;
|
|
343
|
+
try {
|
|
344
|
+
message = JSON.parse(String(raw));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (message.type === "challenge") {
|
|
350
|
+
// whatever holds the port without this key gets no calls from this session
|
|
351
|
+
if (!proves(message.proof, key, "hub", mine, message.nonce)) {
|
|
352
|
+
this.peerProblem = PEER_REJECTED;
|
|
353
|
+
return socket.close();
|
|
354
|
+
}
|
|
355
|
+
proven = true;
|
|
356
|
+
return socket.send(JSON.stringify({ type: "auth", proof: prove(key, "client", message.nonce, mine) }));
|
|
357
|
+
}
|
|
358
|
+
if (message.type === "welcome") {
|
|
359
|
+
if (message.error)
|
|
360
|
+
return;
|
|
361
|
+
if (!proven) {
|
|
362
|
+
this.peerProblem = PEER_REJECTED;
|
|
363
|
+
return socket.close();
|
|
364
|
+
}
|
|
365
|
+
clearTimeout(unanswered);
|
|
366
|
+
this.hub = socket;
|
|
367
|
+
this.role = "peer";
|
|
368
|
+
this.peerProblem = "";
|
|
369
|
+
this.agentChanged();
|
|
370
|
+
return resolve(true);
|
|
371
|
+
}
|
|
372
|
+
if (this.hub === socket && message.type === "result")
|
|
373
|
+
this.settle(message);
|
|
374
|
+
});
|
|
375
|
+
socket.on("close", (code, reason) => {
|
|
376
|
+
clearTimeout(unanswered);
|
|
377
|
+
if (this.hub !== socket) {
|
|
378
|
+
if (code === 1008 && String(reason) === "wrong key")
|
|
379
|
+
this.peerProblem = PEER_REJECTED;
|
|
380
|
+
if (code === 1008 && String(reason).startsWith("protocol"))
|
|
381
|
+
this.peerProblem = PEER_VERSION;
|
|
382
|
+
if (code === 4000 && String(reason) === TAKE_OVER)
|
|
383
|
+
this.peerProblem = "";
|
|
384
|
+
return resolve(false);
|
|
385
|
+
}
|
|
386
|
+
this.hub = null;
|
|
387
|
+
this.role = null;
|
|
388
|
+
this.failAll();
|
|
389
|
+
if (this.closed)
|
|
390
|
+
return;
|
|
391
|
+
if (code !== 1008 || String(reason) !== HANDED_OVER)
|
|
392
|
+
return this.join();
|
|
393
|
+
this.peerProblem = PEER_VERSION;
|
|
394
|
+
setTimeout(() => this.join(), HANDOVER_WAIT);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
onConnection(socket, request) {
|
|
399
|
+
// unhandled, an error such as a message over maxPayload would end the process; ws closes the socket itself
|
|
400
|
+
socket.on("error", error => this.options.log(`dropped a connection: ${error.message}`));
|
|
401
|
+
const origin = request.headers.origin || "";
|
|
402
|
+
if (/^https?:/i.test(origin))
|
|
403
|
+
return socket.close(1008, "web pages can't connect");
|
|
404
|
+
let hello = null;
|
|
405
|
+
let key = "";
|
|
406
|
+
let mine = "";
|
|
407
|
+
socket.on("message", raw => {
|
|
408
|
+
let message;
|
|
409
|
+
try {
|
|
410
|
+
message = JSON.parse(String(raw));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return socket.close(1008, "bad message");
|
|
414
|
+
}
|
|
415
|
+
if (!hello) {
|
|
416
|
+
const extension = message.role === "ext" && EXTENSION_ORIGIN.test(origin);
|
|
417
|
+
const peer = message.role === "peer" && !origin;
|
|
418
|
+
if (message.type !== "hello" || typeof message.nonce !== "string" || (!extension && !peer))
|
|
419
|
+
return socket.close(1008, "unknown client");
|
|
420
|
+
// a newer session goes on to prove the key, and may then take the port (handOver)
|
|
421
|
+
const newer = peer && typeof message.protocol === "number" && message.protocol > PROTOCOL;
|
|
422
|
+
if (message.protocol !== PROTOCOL && !newer) {
|
|
423
|
+
if (extension)
|
|
424
|
+
this.extensionOutdatedAt = Date.now();
|
|
425
|
+
return this.refuseVersion(socket, message.protocol);
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
key = loadKey();
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return socket.close(1011, "no key");
|
|
432
|
+
}
|
|
433
|
+
if (extension)
|
|
434
|
+
this.extensionOutdatedAt = 0;
|
|
435
|
+
hello = message;
|
|
436
|
+
mine = nonce();
|
|
437
|
+
if (extension)
|
|
438
|
+
this.keepAlive(socket);
|
|
439
|
+
return socket.send(JSON.stringify({ type: "challenge", nonce: mine, key_id: keyId(key), proof: prove(key, "hub", message.nonce, mine), agents: this.agents() }));
|
|
440
|
+
}
|
|
441
|
+
if (message.type === "auth") {
|
|
442
|
+
if (!proves(message.proof, key, "client", mine, hello.nonce))
|
|
443
|
+
return socket.close(1008, "wrong key");
|
|
444
|
+
socket.removeAllListeners("message");
|
|
445
|
+
if (hello.role === "ext")
|
|
446
|
+
return this.acceptExtension(socket, hello);
|
|
447
|
+
return hello.protocol === PROTOCOL ? this.acceptPeer(socket) : this.handOver(socket, hello.protocol);
|
|
448
|
+
}
|
|
449
|
+
if (hello.role !== "ext")
|
|
450
|
+
return;
|
|
451
|
+
// approval means Hoverify is asking its user; it sends pair only once they allow
|
|
452
|
+
if (message.type === "approval")
|
|
453
|
+
this.approvals.add(socket);
|
|
454
|
+
if (message.type === "pair")
|
|
455
|
+
socket.send(JSON.stringify({ type: "key", key }));
|
|
456
|
+
});
|
|
457
|
+
socket.on("close", () => {
|
|
458
|
+
this.approvals.delete(socket);
|
|
459
|
+
if (hello)
|
|
460
|
+
this.lastActive = Date.now();
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
refuseVersion(socket, protocol) {
|
|
464
|
+
socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL, error: "version" }));
|
|
465
|
+
socket.close(1008, `protocol ${protocol} is not ${PROTOCOL}`);
|
|
466
|
+
}
|
|
467
|
+
// after an update the port goes to the newer version, unless a browser on this one still uses it: Hoverify may not have updated yet
|
|
468
|
+
handOver(socket, protocol) {
|
|
469
|
+
if (this.browsers.size || this.approvals.size)
|
|
470
|
+
return this.refuseVersion(socket, protocol);
|
|
471
|
+
this.options.log(`a session on protocol ${protocol} takes the port`);
|
|
472
|
+
this.server?.close();
|
|
473
|
+
this.server = null;
|
|
474
|
+
this.role = null;
|
|
475
|
+
this.peerProblem = PEER_VERSION;
|
|
476
|
+
this.failAll();
|
|
477
|
+
for (const peer of this.peers.keys())
|
|
478
|
+
peer.close(1008, HANDED_OVER);
|
|
479
|
+
this.peers.clear();
|
|
480
|
+
socket.close(4000, TAKE_OVER);
|
|
481
|
+
setTimeout(() => this.join(), HANDOVER_WAIT);
|
|
482
|
+
}
|
|
483
|
+
keepAlive(socket) {
|
|
484
|
+
const ping = setInterval(() => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify({ type: "ping" })), PING_INTERVAL);
|
|
485
|
+
socket.on("close", () => clearInterval(ping));
|
|
486
|
+
}
|
|
487
|
+
acceptExtension(socket, hello) {
|
|
488
|
+
const id = validId(hello.browser_id);
|
|
489
|
+
const earlier = this.browsers.get(id);
|
|
490
|
+
// only this browser's own earlier socket makes way; re-inserting keeps the map in connection order
|
|
491
|
+
this.browsers.delete(id);
|
|
492
|
+
if (earlier && earlier.socket !== socket)
|
|
493
|
+
earlier.socket.close(1000, "replaced");
|
|
494
|
+
const entry = { id, socket, label: "", focused: false, focused_at: 0, claims: [], only: false };
|
|
495
|
+
const update = (state) => {
|
|
496
|
+
const label = Browsers.labelOf(state.label);
|
|
497
|
+
const renamed = entry.label !== label;
|
|
498
|
+
entry.label = label;
|
|
499
|
+
entry.focused = state.focused === true;
|
|
500
|
+
entry.focused_at = Number(state.focused_at) || 0;
|
|
501
|
+
entry.claims = Array.isArray(state.claims) ? state.claims.filter(path => typeof path === "string").slice(0, MAX_CLAIMS) : [];
|
|
502
|
+
entry.only = state.only === true;
|
|
503
|
+
if (renamed && this.browsers.get(id) === entry)
|
|
504
|
+
this.pushAgents();
|
|
505
|
+
};
|
|
506
|
+
update(hello);
|
|
507
|
+
this.browsers.set(id, entry);
|
|
508
|
+
const name = () => `${entry.label} (${Browsers.shortId(id)})`;
|
|
509
|
+
this.approvals.delete(socket);
|
|
510
|
+
socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL }));
|
|
511
|
+
this.options.log(`${name()} connected (${hello.browser}, Hoverify ${hello.extension_version})`);
|
|
512
|
+
// the welcome carries no lists: this push gives the new browser its own and updates the others that name it
|
|
513
|
+
this.pushAgents();
|
|
514
|
+
socket.on("message", raw => {
|
|
515
|
+
let message;
|
|
516
|
+
try {
|
|
517
|
+
message = JSON.parse(String(raw));
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (message.type === "browser")
|
|
523
|
+
return update(message);
|
|
524
|
+
if (message.type !== "result")
|
|
525
|
+
return;
|
|
526
|
+
const route = this.routes.get(message.id);
|
|
527
|
+
this.routes.delete(message.id);
|
|
528
|
+
message.browser = id;
|
|
529
|
+
if (route) {
|
|
530
|
+
message.picked = route.picked;
|
|
531
|
+
if (route.status)
|
|
532
|
+
message.browsers = this.list(id);
|
|
533
|
+
}
|
|
534
|
+
if (route?.peer) {
|
|
535
|
+
if (route.peer.readyState === WebSocket.OPEN)
|
|
536
|
+
route.peer.send(JSON.stringify(message));
|
|
537
|
+
}
|
|
538
|
+
else
|
|
539
|
+
this.settle(message);
|
|
540
|
+
});
|
|
541
|
+
socket.on("close", () => {
|
|
542
|
+
for (const [callId, route] of this.routes) {
|
|
543
|
+
if (route.socket !== socket)
|
|
544
|
+
continue;
|
|
545
|
+
this.routes.delete(callId);
|
|
546
|
+
const reset = { type: "result", id: callId, ok: false, error: BRIDGE_RESET };
|
|
547
|
+
if (!route.peer)
|
|
548
|
+
this.settle(reset);
|
|
549
|
+
else if (route.peer.readyState === WebSocket.OPEN)
|
|
550
|
+
route.peer.send(JSON.stringify(reset));
|
|
551
|
+
}
|
|
552
|
+
if (this.browsers.get(id)?.socket !== socket)
|
|
553
|
+
return;
|
|
554
|
+
this.browsers.delete(id);
|
|
555
|
+
this.options.log(`${name()} disconnected`);
|
|
556
|
+
this.pushAgents();
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
acceptPeer(socket) {
|
|
560
|
+
let caller = null;
|
|
561
|
+
socket.send(JSON.stringify({ type: "welcome", protocol: PROTOCOL }));
|
|
562
|
+
socket.on("message", raw => {
|
|
563
|
+
let message;
|
|
564
|
+
try {
|
|
565
|
+
message = JSON.parse(String(raw));
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (message.type === "call") {
|
|
571
|
+
// until the peer reports, the hub knows neither its session nor its project, so nothing can route the call
|
|
572
|
+
if (!caller)
|
|
573
|
+
return socket.close(1008, "call before agent");
|
|
574
|
+
this.relay(socket, message, caller);
|
|
575
|
+
}
|
|
576
|
+
if (message.type === "agent") {
|
|
577
|
+
caller = {
|
|
578
|
+
agent: text(message.agent, 100),
|
|
579
|
+
// named once, so a later report can't take over another session's tabs
|
|
580
|
+
session: caller?.session ?? validId(message.session),
|
|
581
|
+
project: projectFrom(message.project),
|
|
582
|
+
configured: text(message.configured, 100),
|
|
583
|
+
pinned: text(message.pinned, 64),
|
|
584
|
+
asked: message.asked === true,
|
|
585
|
+
};
|
|
586
|
+
this.peers.set(socket, caller);
|
|
587
|
+
this.pushAgents();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
socket.on("close", () => {
|
|
591
|
+
if (this.peers.delete(socket))
|
|
592
|
+
this.pushAgents();
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
relay(peer, message, caller) {
|
|
596
|
+
this.forward(message, peer, caller).catch(error => {
|
|
597
|
+
this.routes.delete(message.id);
|
|
598
|
+
if (peer.readyState === WebSocket.OPEN)
|
|
599
|
+
peer.send(JSON.stringify({ type: "result", id: message.id, ok: false, error: error.message }));
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
package/dist/browsers.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
const LABEL_LIMIT = 40;
|
|
4
|
+
export const shortId = (id) => id.slice(0, 8);
|
|
5
|
+
// a server started in / or the home folder (Claude Desktop does) has no project to speak of
|
|
6
|
+
export function projectOf(path) {
|
|
7
|
+
if (typeof path !== "string" || !path || path.length > 1024 || path === "/" || path === homedir())
|
|
8
|
+
return null;
|
|
9
|
+
return { path, name: basename(path) || path };
|
|
10
|
+
}
|
|
11
|
+
const named = (browser) => `${browser.label} (id ${shortId(browser.id)})`;
|
|
12
|
+
export function labelOf(label) {
|
|
13
|
+
return (typeof label === "string" && label.trim().slice(0, LABEL_LIMIT)) || "Browser";
|
|
14
|
+
}
|
|
15
|
+
// a name can look like the start of an id ("cafe"), so names come before id prefixes
|
|
16
|
+
export function matching(browsers, wanted) {
|
|
17
|
+
const want = wanted.toLowerCase();
|
|
18
|
+
const exact = browsers.filter(browser => browser.id.toLowerCase() === want);
|
|
19
|
+
if (exact.length)
|
|
20
|
+
return exact;
|
|
21
|
+
const byName = browsers.filter(browser => browser.label.toLowerCase() === want);
|
|
22
|
+
if (byName.length || want.length < 4)
|
|
23
|
+
return byName;
|
|
24
|
+
return browsers.filter(browser => browser.id.toLowerCase().startsWith(want));
|
|
25
|
+
}
|
|
26
|
+
export function missing(wanted, connected) {
|
|
27
|
+
const now = connected.length ? ` Connected now: ${connected.map(named).join(", ")}.` : "";
|
|
28
|
+
return `${wanted} isn't connected. Switch on Connect to agent in Hoverify in that browser, or pass browser to use another one.${now}`;
|
|
29
|
+
}
|
|
30
|
+
export function ambiguous(wanted, matches) {
|
|
31
|
+
return `More than one connected browser is named ${wanted}: ${matches.map(named).join(", ")}. Pass one of the ids as browser, or give the browsers different names on Hoverify's AI agent page.`;
|
|
32
|
+
}
|
|
33
|
+
export function refused(label, project) {
|
|
34
|
+
return `${label} only works for the projects the user keeps in it, and ${project ? project.name : "this session's folder"} isn't one. The user can keep it there, or switch off "Only kept projects", on Hoverify's AI agent page in ${label}.`;
|
|
35
|
+
}
|
|
36
|
+
export function allRefused(project, connected) {
|
|
37
|
+
return `Every connected browser (${connected.map(named).join(", ")}) only works for the projects the user keeps in it, and ${project ? project.name : "this session's folder"} isn't one. The user can keep it in one of them on Hoverify's AI agent page.`;
|
|
38
|
+
}
|
|
39
|
+
export function status(picked, configured, project, browsers) {
|
|
40
|
+
const current = browsers.find(browser => browser.current);
|
|
41
|
+
const why = {
|
|
42
|
+
argument: "you asked for it with browser",
|
|
43
|
+
config: `this project's MCP config sets --browser ${configured}`,
|
|
44
|
+
claim: `the user keeps ${project?.name || "this project"} in it`,
|
|
45
|
+
pinned: "this session has been working in it",
|
|
46
|
+
last_used: "it's the browser the user used last",
|
|
47
|
+
moved: "the browser this session was using isn't connected anymore",
|
|
48
|
+
};
|
|
49
|
+
const lines = [`Browser: ${named(current)}, because ${why[picked]}.`];
|
|
50
|
+
if (project)
|
|
51
|
+
lines.push(`Project: ${project.name} (${project.path})`);
|
|
52
|
+
if (browsers.length > 1) {
|
|
53
|
+
lines.push(`Hoverify is on in ${browsers.length} browsers, most recently used first:`);
|
|
54
|
+
lines.push(...browsers.map(browser => `- ${named(browser)}${browser.current ? ", this session's" : ""}`));
|
|
55
|
+
lines.push("To use another one, pass its name as browser to any tool, or its id when two share a name. The session stays there afterwards.");
|
|
56
|
+
}
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|