@agent360/browser-mcp 1.25.0 → 1.29.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/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Agent360 Browser MCP Server
4
4
  *
5
5
  * Bridges Claude Code (stdio MCP) to Chrome Extension (WebSocket).
6
- * Auto-selects first available port in range 9876-9885 for multi-session support.
6
+ * Auto-selects first available port in range 9876-9895 for multi-session support.
7
7
  *
8
8
  * Architecture:
9
9
  * Claude Code ←(stdio)→ this process ←(WS :port)→ Offscreen Doc ←(sendMessage)→ Service Worker → Chrome APIs
@@ -11,12 +11,14 @@
11
11
 
12
12
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { ledErDoedt, forfaedreKaede } from './vagt.js';
14
15
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
15
16
  import { WebSocketServer } from 'ws';
16
- import { execSync } from 'child_process';
17
- import { dirname, join, resolve } from 'path';
17
+ import { execSync, execFile } from 'child_process';
18
+ import { dirname, join, resolve, sep } from 'path';
18
19
  import { fileURLToPath } from 'url';
19
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
20
+ import { homedir } from 'os';
21
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs';
20
22
  import { TOOLS, PROVIDER_PAGES } from './tools.js';
21
23
 
22
24
  // Read version from package.json — single source of truth, never drifts
@@ -29,33 +31,118 @@ const PKG_VERSION = JSON.parse(
29
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
32
  const repoDir = dirname(__dirname); // parent of mcp-server/
31
33
 
34
+ // FJERNET 22/8: her koerte `git pull --ff-only` + `npm install` ved hver serveropstart,
35
+ // med cwd = pakkens foraeldremappe. I en npm-installation er det node_modules/@agent360/ —
36
+ // og git soeger OPAD, saa kaldet landede i BRUGERENS EGET repo. Maalt: fra
37
+ // node_modules/@agent360 opløser git toplevel til det omkringliggende projekt.
38
+ // En agent-session maatte altsaa ikke mutere brugerens git-trae uden samtykke.
39
+ // `npx @agent360/browser-mcp@latest` opdaterer allerede serveren; blokken var overfloedig.
32
40
  let extensionUpdated = false;
33
- try {
34
- const before = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
35
- execSync('git pull --ff-only 2>/dev/null', { cwd: repoDir, timeout: 10000 });
36
- const after = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
37
- if (before !== after) {
38
- extensionUpdated = true;
39
- process.stderr.write(`[MCP] Updated to ${after.slice(0, 8)} — extension reload recommended\n`);
40
- // Check if npm deps changed
41
- try {
42
- const diff = execSync(`git diff ${before} ${after} -- mcp-server/package.json`, { cwd: repoDir }).toString();
43
- if (diff) {
44
- execSync('npm install --silent', { cwd: `${repoDir}/mcp-server`, timeout: 30000 });
45
- process.stderr.write('[MCP] Dependencies updated\n');
46
- }
47
- } catch {}
48
- } else {
49
- process.stderr.write('[MCP] Already up to date\n');
41
+
42
+ // Spaendet kan flyttes med env ellers ville en test af port-udsultning skulle
43
+ // beslaglaegge de RIGTIGE porte og dermed sulte brugerens oevrige chats imens.
44
+ // Uden env er vaerdierne uaendrede. (Samme moenster som BROWSER_MCP_EXTENSION_ID.)
45
+ const BASE_PORT = Number(process.env.BROWSER_MCP_BASE_PORT) || 9876;
46
+ const MAX_PORT = Number(process.env.BROWSER_MCP_MAX_PORT) || 9895; // 20 ports instead of 10 — zombies die within 5s via parent check
47
+
48
+ // ── Extension connections ───────────────────────────────────────────────────
49
+ // FEJL MAALT 21/8: her stod `let extensionSocket = null`, og hver ny forbindelse
50
+ // overskrev den. Er der to udgaver af udvidelsen indlaest i den samme Chrome —
51
+ // fx en "load unpacked"-kopi ved siden af en anden — scanner BEGGE de samme porte
52
+ // og forbinder til hver eneste server. Maalt med lsof: 2 ESTABLISHED forbindelser
53
+ // paa hver af de fire aktive porte. Kommandoerne gik til den der forbandt sidst,
54
+ // mens den anden holdt sit eget sessions-kort og sine egne fane-grupper — og et
55
+ // `terminate` fra den forkerte kopi lukkede serveren ned under den rigtige.
56
+ //
57
+ // Nu holdes alle forbindelser med deres identitet, kommandoer sendes kun til den
58
+ // nyeste udgave, og konflikten kan ses (browser_provide_feedback) i stedet for at
59
+ // vise sig som faner der "forsvinder".
60
+ const connections = new Set(); // { ws, seq, extensionId, version, name, since }
61
+ let connSeq = 0;
62
+ let activePort = null;
63
+ let alleePorteOptaget = false; // hele spaendet i brug — se createWSS
64
+ let bindFejl = null; // bind fejlede af en ANDEN grund end optaget port
65
+
66
+ function cmpVersion(a, b) {
67
+ const pa = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
68
+ const pb = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
69
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
70
+ const d = (pa[i] || 0) - (pb[i] || 0);
71
+ if (d) return d > 0 ? 1 : -1;
50
72
  }
51
- } catch (e) {
52
- process.stderr.write(`[MCP] Auto-update skipped: ${e.message?.split('\n')[0]}\n`);
73
+ return 0;
53
74
  }
54
75
 
55
- const BASE_PORT = 9876;
56
- const MAX_PORT = 9895; // 20 ports instead of 10 — zombies die within 5s via parent check
57
- let extensionSocket = null;
58
- let activePort = null;
76
+ let sidsteKonfliktNoegle = '';
77
+ function advarOmKonflikt(conn) {
78
+ const alle = distinctExtensions();
79
+ if (alle.length < 2) return;
80
+ // Samme konflikt maa ikke skrige ved hver eneste hello — kun naar billedet aendrer sig.
81
+ const noegle = alle.map(c => `${c.extensionId || 'ukendt'}@${c.version || '?'}`).sort().join('|');
82
+ if (noegle === sidsteKonfliktNoegle) return;
83
+ sidsteKonfliktNoegle = noegle;
84
+ const aktiv = activeConnection();
85
+ // Oplyser INGEN af dem en version (alle udgivne udgaver er fra foer haandtrykket),
86
+ // er der intet grundlag for at vaelge. Det skal staa der — ellers laeser man
87
+ // "kommandoer sendes kun til X" som om X var det rigtige valg.
88
+ const kanVaelge = alle.some(c => c.version);
89
+ process.stderr.write(
90
+ `[MCP] ADVARSEL: ${alle.length} Browser MCP-udvidelser er forbundet til denne server samtidig ` +
91
+ `(${alle.map(c => `${c.extensionId || 'ukendt id'}${c.version ? ' v' + c.version : ''}`).join(', ')}). ` +
92
+ 'De deler faner og sessions-tilstand, saa faner kan se ud til at forsvinde. ' +
93
+ (kanVaelge
94
+ ? `Kommandoer sendes kun til den nyeste (${aktiv?.extensionId}). `
95
+ : `Ingen af dem oplyser sin version, saa valget (${aktiv?.extensionId}) er vilkaarligt og kan skifte. `) +
96
+ 'Ret det ved at slaa alle paa naer én fra paa chrome://extensions.\n',
97
+ );
98
+ }
99
+
100
+ function liveConnections() {
101
+ return [...connections].filter(c => c.ws.readyState === 1);
102
+ }
103
+
104
+ // Valget LAASES for serverens levetid.
105
+ //
106
+ // MAALT 21/8 i flow-harnessen: uden laasen skiftede den aktive udvidelse MIDT i en
107
+ // koersel. Foerste kommando (navigate) gik til udvidelse A, som aabnede fanen. Et
108
+ // oejeblik senere forbandt udvidelse B og overtog, fordi den var nyere i raekken —
109
+ // men B kendte ikke A's fane og lavede en frisk about:blank. Alt derefter fejlede med
110
+ // "Cannot access contents of url about:blank". 21 af 43 vaerktoejer faldt paa det, og
111
+ // symptomet lignede praecis "faner forsvinder" og "kun én session virker".
112
+ //
113
+ // Faner hoerer til den udvidelse der aabnede dem. Skifter man udvidelse, strander de.
114
+ // Derfor: vaelg én gang, og bliv ved den saa laenge dens forbindelse lever. Doer den,
115
+ // vaelges der forfra — det er en aegte genopretning, ikke et vilkaarligt skift.
116
+ let laastForbindelse = null;
117
+ let harSendtKommando = false;
118
+ const PINNET_UDVIDELSE = (process.env.BROWSER_MCP_EXTENSION_ID || '').trim() || null;
119
+
120
+ function activeConnection() {
121
+ if (laastForbindelse && laastForbindelse.ws.readyState === 1) return laastForbindelse;
122
+
123
+ // Nyeste udvidelse vinder. Ved uafgjort: den der forbandt sidst. En udvidelse fra
124
+ // foer haandtrykket har ingen version og taber til en der har én — haandtrykket kom
125
+ // med den nyere udgave.
126
+ let best = null;
127
+ for (const c of liveConnections()) {
128
+ if (!best) { best = c; continue; }
129
+ const d = cmpVersion(c.version, best.version);
130
+ if (d > 0 || (d === 0 && c.since > best.since)) best = c;
131
+ }
132
+ laastForbindelse = best;
133
+ return best;
134
+ }
135
+
136
+ // Én post pr. udvidelse. En udvidelse uden identitet taelles for sig selv (dens
137
+ // socket er noeglen), saa to gamle kopier stadig ses som to.
138
+ function distinctExtensions() {
139
+ const byKey = new Map();
140
+ for (const c of liveConnections()) {
141
+ const key = c.extensionId || `legacy:${c.seq}`;
142
+ if (!byKey.has(key)) byKey.set(key, c);
143
+ }
144
+ return [...byKey.values()];
145
+ }
59
146
  let wss = null; // Track WSS for graceful shutdown
60
147
  let cmdId = 0;
61
148
  let lastActivity = Date.now();
@@ -64,11 +151,46 @@ const pending = new Map();
64
151
  // Timers hoisted to module scope so gracefulShutdown can clear them deterministically.
65
152
  let heartbeat = null;
66
153
  let parentCheck = null;
154
+ let tomgangsvagt = null;
155
+
156
+ // ── 4-timers-tomgangen bor paa modul-niveau, ikke i hjerteslaget ────────────
157
+ //
158
+ // FUNDET AF REVIEW 7/9. Tjekket laa INDE i `heartbeat`, som kun findes mens serveren
159
+ // har en port — og som `frigivPort` rydder. Da porten blev doven, betoed det at en
160
+ // proces der ALDRIG binder (en chat der ikke roerer browseren) heller aldrig faar
161
+ // sin tomgang tjekket. Det var den eneste vej hvor en browser-inaktiv server gav sine
162
+ // ~35 MB tilbage; med mange samtidige chats er det maalbart. Nu koerer vagten altid.
163
+ // Graensen staar som literal, ikke bag en konstant: `vagter.test.mjs` laeser tallet
164
+ // direkte ud af kilden for at haandhaeve at den aldrig bliver kort. En kort graense
165
+ // ville lade en aaben chat miste browseren permanent — vaerre end en port der staar
166
+ // optaget lidt for laenge.
167
+ tomgangsvagt = setInterval(() => {
168
+ if (Date.now() - lastActivity > 4 * 60 * 60 * 1000) gracefulShutdown('Idle timeout (4h)');
169
+ }, 60000);
67
170
 
68
171
  // ── WebSocket Server ───────────────────────────────────────────────────────
69
172
 
70
173
  function createWSS(port = BASE_PORT) {
71
- const server = new WebSocketServer({ host: '127.0.0.1', port });
174
+ const server = new WebSocketServer({
175
+ host: '127.0.0.1',
176
+ port,
177
+ // Afvis allerede i HAANDTRYKKET, ikke efter. MAALT 23/8: lukkede vi foerst
178
+ // forbindelsen inde i 'connection', naaede den fremmede at faa en aaben socket
179
+ // (og et 101-svar) foer den blev smidt ud. Med verifyClient faar den 401 og
180
+ // ingen socket overhovedet.
181
+ //
182
+ // Chrome saetter ALTID Origin: chrome-extension://<32 tegn> paa en WebSocket fra
183
+ // en udvidelse — verificeret mod den koerende. Alt andet er per definition ikke
184
+ // en udvidelse, saa gaten koster aegte brugere ingenting.
185
+ verifyClient: ({ origin }, godkend) => {
186
+ if (/^chrome-extension:\/\/[a-p]{32}$/.test(origin || '')) return godkend(true);
187
+ process.stderr.write(
188
+ `[MCP] Afviser opkobling uden gyldig chrome-extension-Origin` +
189
+ `${origin ? ` (fik "${String(origin).slice(0, 60)}")` : ' (ingen Origin-header)'}\n`,
190
+ );
191
+ godkend(false, 401, 'only chrome extensions may connect');
192
+ },
193
+ });
72
194
  wss = server;
73
195
 
74
196
  server.on('error', (err) => {
@@ -77,16 +199,81 @@ function createWSS(port = BASE_PORT) {
77
199
  process.stderr.write(`[MCP] Port ${port} in use, trying ${port + 1}...\n`);
78
200
  createWSS(port + 1);
79
201
  } else {
202
+ // MAALT 22/8: her stod KUN denne stderr-linje. Ingen laeser stderr fra en
203
+ // MCP-server, saa udtoemte porte var en helt tavs fejl. Hvert vaerktoejskald
204
+ // fejlede bagefter med "extension not connected" — en tekst der oven i koebet
205
+ // siger at serveren koerer og sender brugeren til Chrome Web Store. Begge dele
206
+ // er forkerte naar sandheden er at vi aldrig fik en port. Flaget laeses i
207
+ // sendToExtension, saa agenten kan give brugeren den rigtige forklaring.
208
+ alleePorteOptaget = true;
80
209
  process.stderr.write(`[MCP] All ports ${BASE_PORT}-${MAX_PORT} in use. Cannot start.\n`);
210
+ loesPortLoefte(false);
81
211
  }
82
212
  } else {
213
+ // FUNDET AF REVIEW 7/9. Her stod KUN stderr-linjen. Foer porten blev doven, var
214
+ // det harmloest: en opstartsfejl man kunne se i loggen. Nu venter `sikrePort()`
215
+ // paa et loefte der aldrig blev indfriet — og `sendToExtension` goer
216
+ // `await sikrePort()` UDEN timeout. Enhver anden bind-fejl end EADDRINUSE
217
+ // (EACCES paa en privilegeret port, EADDRNOTAVAIL, en restriktiv firewall)
218
+ // ville derfor faa hvert eneste browser-kald til at haenge tavst for evigt.
219
+ // At haenge uden besked er vaerre end den fejl vi rettede.
220
+ bindFejl = err.message;
83
221
  process.stderr.write(`[MCP] WebSocket error: ${err.message}\n`);
222
+ loesPortLoefte(false);
84
223
  }
85
224
  });
86
225
 
87
- server.on('connection', (ws) => {
88
- extensionSocket = ws;
89
- process.stderr.write(`[MCP] Chrome extension connected on port ${port}\n`);
226
+ server.on('connection', (ws, req) => {
227
+ // WebSocket-handshaket fra en udvidelse baerer Origin: chrome-extension://<id>.
228
+ // Den identificerer afsenderen UDEN at udvidelsen behoever at kende haandtrykket,
229
+ // saa en konflikt mellem to indlaeste udvidelser kan opdages ogsaa naar begge er
230
+ // gamle udgaver — hvilket er praecis den situation konflikten opstaar i.
231
+ // Maalt 21/8: to distinkte origins ringede op til hver eneste server.
232
+ const origin = req?.headers?.origin || '';
233
+ const fraOrigin = /^chrome-extension:\/\/([a-p]{32})$/.exec(origin)?.[1] || null;
234
+
235
+ // ── Kun Chrome-udvidelser lukkes ind (MAALT 23/8) ─────────────────────────
236
+ //
237
+ // Reproduceret med en raa WebSocket-klient mod en aegte server: en klient der
238
+ // simpelthen UDELOD Origin-headeren blev accepteret, vandt rollen som aktiv
239
+ // udvidelse med `hello version 99.0.0`, fik `browser_get_cookies` leveret, og
240
+ // kunne lukke serveren med `terminate`. Alle tre trin lykkedes.
241
+ //
242
+ // Serveren lytter kun paa 127.0.0.1, saa angriberen skal koere lokalt — men det
243
+ // goer enhver anden app og ethvert npm-postinstall-script. Og hvad den kan er
244
+ // ikke smaating: laese alt agenten sender til browseren (kodeord fra ask_user,
245
+ // cookies, sidetekst), fodre agenten med opdigtet sideindhold, og slukke
246
+ // browser-adgangen i alle aabne chats.
247
+ //
248
+ // Chrome saetter ALTID `Origin: chrome-extension://<id>` paa en WebSocket fra en
249
+ // udvidelse — verificeret mod den koerende udvidelse. En manglende header er
250
+ // derfor ikke en aeldre udgave; det er noget andet end en udvidelse.
251
+ if (!fraOrigin) {
252
+ process.stderr.write(
253
+ "[MCP] Afviser forbindelse uden gyldig chrome-extension-Origin" +
254
+ (origin ? ` (fik "${origin.slice(0, 60)}")` : " (ingen Origin-header)") + "\n",
255
+ );
256
+ try { ws.close(1008, "only chrome extensions may connect"); } catch {}
257
+ return;
258
+ }
259
+
260
+ // Noedudgang naar flere udvidelser er indlaest og brugeren ikke kan eller vil
261
+ // slaa dem fra: BROWSER_MCP_EXTENSION_ID=<id> binder serveren til én bestemt.
262
+ // Uden den er valget vilkaarligt naar ingen af dem oplyser en version.
263
+ if (PINNET_UDVIDELSE && fraOrigin && fraOrigin !== PINNET_UDVIDELSE) {
264
+ process.stderr.write(`[MCP] Afviser udvidelse ${fraOrigin} — bundet til ${PINNET_UDVIDELSE}\n`);
265
+ try { ws.close(1008, 'not the pinned extension'); } catch {}
266
+ return;
267
+ }
268
+
269
+ const conn = { ws, seq: ++connSeq, extensionId: fraOrigin, version: null, name: null,
270
+ harHilst: false, helloId: null, since: Date.now() };
271
+ connections.add(conn);
272
+ // Har vi endnu ikke sendt en eneste kommando, er ingen faner i spil, og en
273
+ // nytilkommen udvidelse maa gerne komme i betragtning igen.
274
+ if (!harSendtKommando) laastForbindelse = null;
275
+ process.stderr.write(`[MCP] Chrome extension connected on port ${port}${fraOrigin ? ` (${fraOrigin})` : ''}\n`);
276
+ advarOmKonflikt(conn);
90
277
 
91
278
  // If extension was auto-updated, trigger reload
92
279
  if (process.env.BROWSER_MCP_EXTENSION_UPDATED === '1') {
@@ -101,8 +288,58 @@ function createWSS(port = BASE_PORT) {
101
288
  let msg;
102
289
  try { msg = JSON.parse(data.toString()); } catch { return; }
103
290
 
291
+ // Identitets-haandtryk fra offscreen-dokumentet (v1.28+).
292
+ if (msg.type === 'hello') {
293
+ // MAALT 23/8: her stod `conn.extensionId = msg.extensionId` — altsaa lod
294
+ // haandtrykket afsenderen OVERSKRIVE sin egen identitet med hvad som helst.
295
+ // Origin-headeren er den eneste kilde Chrome selv saetter og som afsenderen
296
+ // ikke kan forfalske, saa den vinder. Beskedens id gemmes separat: stemmer de
297
+ // ikke overens, er noget galt, og saa maa forbindelsen ikke lukke serveren ned.
298
+ conn.helloId = typeof msg.extensionId === 'string' ? msg.extensionId : null;
299
+ conn.harHilst = true;
300
+ if (conn.helloId && conn.helloId !== conn.extensionId) {
301
+ process.stderr.write(
302
+ `[MCP] Haandtryk oplyser ${conn.helloId} men Origin siger ${conn.extensionId} — ` +
303
+ 'bruger Origin\n',
304
+ );
305
+ }
306
+ conn.version = typeof msg.version === 'string' ? msg.version : null;
307
+ conn.name = typeof msg.name === 'string' ? msg.name : null;
308
+ advarOmKonflikt(conn);
309
+ return;
310
+ }
311
+
104
312
  if (msg.type === 'terminate') {
105
- gracefulShutdown('Terminate signal from extension (last tab closed)');
313
+ // terminate lukker serveren for ALLE chats paa porten, saa den har to gates.
314
+ //
315
+ // Om id-sammenligningen: ORIGIN er autoriteten — Chrome saetter den, og
316
+ // afsenderen kan ikke forfalske den. Haandtrykkets id er kun et ekstra
317
+ // signal. MAALT 23/8 mod den AEGTE udvidelse: den sender hello med id null,
318
+ // fordi chrome.runtime.getManifest() kan fejle i offscreen-dokumentet.
319
+ // Kraevede vi lighed ubetinget, kunne en HELT legitim udvidelse aldrig lukke
320
+ // sin session ned — en fejl jeg selv indfoerte samme aften. Derfor: et id der
321
+ // MANGLER er fint (Origin har allerede bevist hvem det er), mens et id der er
322
+ // TIL STEDE og peger et ANDET sted afvises.
323
+ //
324
+ // 1) Afsenderen skal have sendt et hello der stemmer med sin egen Origin.
325
+ // MAALT 23/8: uden den kunne en forbindelse der lige havde vundet rollen
326
+ // som aktiv slukke browser-adgangen med én besked.
327
+ // 2) Afsenderen skal VAERE den aktive. Uden den kunne en gammel sidelaebende
328
+ // kopi, der lukkede sin sidste fane, rive serveren vaek under den
329
+ // udvidelse der reelt loeste opgaven.
330
+ if (!conn.harHilst || (conn.helloId && conn.helloId !== conn.extensionId)) {
331
+ process.stderr.write('[MCP] terminate ignoreret — intet gyldigt haandtryk\n');
332
+ return;
333
+ }
334
+ if (activeConnection() !== conn) {
335
+ process.stderr.write('[MCP] terminate ignoreret — kom fra en inaktiv udvidelses-forbindelse\n');
336
+ return;
337
+ }
338
+ // AENDRET 7/9: her stod `gracefulShutdown`. En chat der var faerdig med browseren
339
+ // mistede altsaa browseren HELT — og porten blev alligevel hverken frigivet hurtigt
340
+ // nok til andre (processen doede foerst efter oprydning) eller genvundet af chatten
341
+ // selv. Nu slippes kun porten; naeste browser-kald tager en ny (se sikrePort).
342
+ frigivPort('udvidelsen meldte: sidste fane lukket');
106
343
  return;
107
344
  }
108
345
 
@@ -115,42 +352,173 @@ function createWSS(port = BASE_PORT) {
115
352
  else p.resolve(result);
116
353
  });
117
354
 
118
- ws.on('close', () => {
119
- if (extensionSocket === ws) {
120
- extensionSocket = null;
121
- process.stderr.write(`[MCP] Chrome extension disconnected\n`);
355
+ // ── En doed socket skal AFVISE de kald der var undervejs (MAALT 23/8) ──────
356
+ // Foer roerte close-handleren ikke `pending`. Maalt: en socket der doede 300 ms
357
+ // inde i et kald gav foerst svar efter 30.011 ms — og med den FORKERTE
358
+ // forklaring, "kommandoen tog for lang tid". For extract_list er timeouten 180
359
+ // sekunder, altsaa tre minutters tavshed hvor sandheden var kendt med det samme.
360
+ const afvisVentende = (grund) => {
361
+ // Kun naar ingen anden levende forbindelse kan svare — ellers ville et helt
362
+ // normalt skift mellem to udvidelser afbryde kald der er fuldt i orden.
363
+ if (!pending.size || liveConnections().length) return;
364
+ const antal = pending.size;
365
+ for (const [id, p] of pending) {
366
+ clearTimeout(p.timer);
367
+ pending.delete(id);
368
+ p.reject(new Error(
369
+ `Forbindelsen til Chrome-udvidelsen forsvandt mens kommandoen koerte (${grund}). ` +
370
+ 'Kommandoen naaede maaske at blive udfoert i browseren — tjek tilstanden foer du ' +
371
+ 'proever igen. Er udvidelsen slaaet fra eller Chrome lukket, saa start den og proev forfra.',
372
+ ));
122
373
  }
374
+ process.stderr.write(`[MCP] ${antal} ventende kald afvist — ${grund}\n`);
375
+ };
376
+
377
+ // MAALT 23/8: der fandtes INGEN error-handler. Et ugyldigt WebSocket-frame (fx
378
+ // RSV1 sat) faar 'ws' til at emitte 'error' paa socketen, og en uhaandteret
379
+ // 'error' paa en EventEmitter kaster og draeber hele processen — altsaa alle
380
+ // chats paa den port. Tre linjer lukker det.
381
+ ws.on('error', (e) => {
382
+ process.stderr.write(`[MCP] WebSocket-fejl paa forbindelsen: ${e?.message || e}\n`);
383
+ try { ws.close(); } catch {}
384
+ });
385
+
386
+ ws.on('close', () => {
387
+ connections.delete(conn);
388
+ afvisVentende('udvidelsen koblede fra');
389
+ process.stderr.write(`[MCP] Chrome extension disconnected (${liveConnections().length} tilbage)\n`);
123
390
  });
124
391
  });
125
392
 
126
393
  server.on('listening', () => {
127
394
  activePort = port;
128
395
  process.stderr.write(`[MCP] WebSocket server listening on ws://127.0.0.1:${port}\n`);
396
+ loesPortLoefte(true);
129
397
  });
130
398
 
131
399
  // Heartbeat + idle timeout (4 hours) — hoisted to module scope so gracefulShutdown can clear it
400
+ // Ryddes foerst: porten kan bindes flere gange i samme proces (se sikrePort), og uden
401
+ // det her ville hver ny binding efterlade en ekstra timer der aldrig blev stoppet.
402
+ if (heartbeat) clearInterval(heartbeat);
132
403
  heartbeat = setInterval(() => {
133
- if (extensionSocket && extensionSocket.readyState === 1) {
134
- extensionSocket.ping();
135
- }
136
- if (Date.now() - lastActivity > 4 * 60 * 60 * 1000) {
137
- gracefulShutdown('Idle timeout (4h)');
138
- }
404
+ for (const c of liveConnections()) c.ws.ping();
139
405
  }, 20000);
140
406
  }
141
407
 
142
- createWSS();
408
+ // ── Porten tages ved BRUG, ikke ved opstart ─────────────────────────────────
409
+ //
410
+ // MAALT 7/9-2026 paa Gustavs maskine: 37 koerende servere, 20 porte i spaendet, 17 chats
411
+ // helt uden browser. Hver Claude Code-chat starter en server ved opstart — ogsaa de mange
412
+ // chats der aldrig roerer browseren — og her stod `createWSS()` paa modul-niveau. Porten
413
+ // blev altsaa reserveret af en chat der maaske aldrig fik brug for den, og holdt indtil
414
+ // chatten doede eller 4-timers-tomgangen udloeb.
415
+ //
416
+ // Konsekvensen var ensidigt slem: chat nr. 21 fik `alleePorteOptaget = true` ÉN gang og
417
+ // proevede aldrig igen — dens browser var doed hele chattens levetid, selv naar en port
418
+ // blev fri et minut senere.
419
+ //
420
+ // Nu bindes porten foerste gang et vaerktoej faktisk skal bruge udvidelsen, og HVERT kald
421
+ // proever igen hvis det forrige ikke fik en. Udvidelsen genscanner hele spaendet hvert
422
+ // 2. sekund (offscreen.js), saa en port der aabnes sent bliver fundet af sig selv.
423
+ //
424
+ // Bemaerk hvorfor det ikke er en 5-minutters timer der draeber processen: en chat der
425
+ // foerst skal bruge browseren efter en halv time ville saa staa uden. Processen lever
426
+ // videre — det er kun PORTEN der ikke holdes reserveret til noget der ikke sker.
427
+ let portResolver = null;
428
+ let bindLoefte = null;
429
+
430
+ function loesPortLoefte(fik) {
431
+ if (!portResolver) return;
432
+ const r = portResolver;
433
+ portResolver = null;
434
+ bindLoefte = null;
435
+ r(fik);
436
+ }
437
+
438
+ // Slip porten, men BLIV I LIVE. Forskellen er hele pointen: lukkede vi processen ned,
439
+ // ville en chat der er faerdig med browseren kl. 10 og skal bruge den igen kl. 10:40 staa
440
+ // uden — og Claude Code genstarter ikke en MCP-server midt i en samtale. Processen koster
441
+ // ~35 MB og ingen port; det er porten der er den knappe ressource.
442
+ function frigivPort(grund) {
443
+ if (activePort === null) return;
444
+ process.stderr.write(`[MCP] frigiver port ${activePort} — ${grund}\n`);
445
+ if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
446
+ const gammel = wss;
447
+ wss = null;
448
+ activePort = null;
449
+ laastForbindelse = null;
450
+ harSendtKommando = false; // naeste binding vaelger udvidelse forfra
451
+ for (const c of connections) { try { c.ws.close(); } catch {} }
452
+ connections.clear();
453
+ try { gammel?.close(); } catch {}
454
+ }
455
+
456
+ function sikrePort() {
457
+ if (activePort !== null) return Promise.resolve(true);
458
+ if (bindLoefte) return bindLoefte; // en binding er allerede i gang
459
+ alleePorteOptaget = false; // hvert forsoeg starter paa en frisk
460
+ bindFejl = null;
461
+ bindLoefte = new Promise((res) => { portResolver = res; });
462
+ // Vagthund. F8 var ét konkret hul hvor loeftet aldrig blev indfriet; det her lukker
463
+ // KLASSEN. Emitter en fremtidig fejlsti hverken 'listening' eller 'error', svarer
464
+ // kaldet nu med en fejl i stedet for at haenge tavst for evigt. 10 s er rigeligt:
465
+ // en loopback-binding tager millisekunder, og hele spaendet naas paa under ét.
466
+ const vagthund = setTimeout(() => {
467
+ if (!portResolver) return;
468
+ bindFejl = bindFejl || 'bindingen svarede ikke inden for 10 sekunder';
469
+ process.stderr.write('[MCP] port-bindingen svarede aldrig — opgiver dette forsoeg\n');
470
+ loesPortLoefte(false);
471
+ }, 10000);
472
+ bindLoefte.finally(() => clearTimeout(vagthund));
473
+ createWSS();
474
+ return bindLoefte;
475
+ }
143
476
 
144
477
  // ── Send command to extension ───────────────────────────────────────────────
145
478
 
146
479
  async function sendToExtension(method, params = {}, timeoutMs = 30000, _retries = 5) {
480
+ // Skaf en port hvis vi ikke har en. Foerste kald binder; senere kald er en no-op.
481
+ // Fik vi ingen (hele spaendet optaget), proever naeste kald igen — derfor ingen kast her.
482
+ await sikrePort();
147
483
  // Retry if extension is temporarily disconnected (reconnects every 2s)
148
- if (!extensionSocket || extensionSocket.readyState !== 1) {
484
+ const conn = activeConnection();
485
+ if (!conn) {
149
486
  if (_retries > 0) {
150
487
  await new Promise(r => setTimeout(r, 1500));
151
488
  return sendToExtension(method, params, timeoutMs, _retries - 1);
152
489
  }
153
- throw new Error('Chrome extension not connected after 5 retries. Open Chrome and ensure Agent360 Browser MCP extension is installed.');
490
+ // This is the other half of the two-part setup: the server is clearly running (it is
491
+ // throwing this), so what is missing is the extension, Chrome itself, or the connection
492
+ // between them. Say which, and where to get it — the agent relays this text to the user.
493
+ if (bindFejl) {
494
+ throw new Error(
495
+ `Serveren kunne ikke aabne en port paa 127.0.0.1 (${BASE_PORT}-${MAX_PORT}): ${bindFejl}\n` +
496
+ 'Det er IKKE Chrome eller udvidelsen — det er operativsystemet eller en firewall der ' +
497
+ 'afviser bindingen. Tjek om noget blokerer loopback-porte.\n' +
498
+ 'Sig praecis dét til brugeren. Sig IKKE at udvidelsen mangler.',
499
+ );
500
+ }
501
+ if (alleePorteOptaget) {
502
+ throw new Error(
503
+ `Alle porte ${BASE_PORT}-${MAX_PORT} er optaget lige nu, saa dette kald fik ingen port. ` +
504
+ 'Det er IKKE et problem med Chrome eller udvidelsen — de virker fint.\n' +
505
+ `${MAX_PORT - BASE_PORT + 1} andre chats bruger browseren i oejeblikket. ` +
506
+ 'Luk en af dem, eller vent til en bliver faerdig — og proev saa kommandoen igen. ' +
507
+ 'Denne chat skal IKKE genstartes: hvert kald proever selv at faa en port.\n' +
508
+ 'Sig praecis dét til brugeren. Sig IKKE at udvidelsen mangler.',
509
+ );
510
+ }
511
+ throw new Error(
512
+ 'Chrome extension not connected after 5 retries.\n' +
513
+ 'Browser MCP needs BOTH halves: this MCP server (running) and the Agent360 Browser MCP ' +
514
+ 'Chrome extension (apparently not reachable).\n' +
515
+ 'Check, in order:\n' +
516
+ ' 1. Chrome is actually open and running.\n' +
517
+ ' 2. The extension is installed and enabled at chrome://extensions — install it from\n' +
518
+ ' https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl\n' +
519
+ ' 3. Click the extension icon -> Reconnect, and wait 2-3 seconds.\n' +
520
+ 'Still stuck: https://browsermcp.dev/docs/troubleshooting/'
521
+ );
154
522
  }
155
523
  return new Promise((resolve, reject) => {
156
524
  const id = ++cmdId;
@@ -159,7 +527,10 @@ async function sendToExtension(method, params = {}, timeoutMs = 30000, _retries
159
527
  reject(new Error(`Command timed out after ${timeoutMs}ms: ${method}`));
160
528
  }, timeoutMs);
161
529
  pending.set(id, { resolve, reject, timer });
162
- extensionSocket.send(JSON.stringify({ id, method, params }));
530
+ // pid = Claude Code-processen der ejer denne server. Udvidelsen bruger den til at
531
+ // skelne 'samme chat, ny forbindelse' fra 'en anden chat' naar den adopterer sessioner.
532
+ harSendtKommando = true;
533
+ conn.ws.send(JSON.stringify({ id, method, params, pid: process.ppid }));
163
534
  });
164
535
  }
165
536
 
@@ -244,13 +615,27 @@ If the extension files were updated, ask the user to reload it:
244
615
  "The Browser MCP extension was updated. Please go to chrome://extensions, find 'Agent360 Browser MCP', and click the reload icon (🔄) to apply the update."
245
616
  You cannot navigate to chrome:// pages — the user must do this manually.
246
617
 
618
+ ## When Browser MCP itself is the obstacle — call browser_provide_feedback
619
+ Before you tell the user "browser-mcp can't do X", or give up on a tool that errored, timed out, or did something other than what it promised: call **browser_provide_feedback** with what_happened. Do it yourself, without asking. It checks the install first — this server against the latest on npm, the extension against this server, and whether more than one Browser MCP extension is connected at once — because those three explain most "it's broken" moments, and none of them are visible to you otherwise. Read the verdict:
620
+ - **outdated** or **disconnected** → relay fix_steps to the user, then retry the action. An outdated install explains most failures, so try the fix first — but if the behaviour still looks wrong after that, report it anyway. A release window is not a reason to stay silent about a real bug.
621
+ - **conflict** → more than one extension is loaded; tabs and sessions will keep behaving randomly until the user disables the extras at chrome://extensions. Say so plainly.
622
+ - **current** → the install is fine, so this is a genuine gap. Offer the returned submit_url as a clickable link.
623
+ - **unknown** → nothing local looks wrong, but the npm comparison was skipped (it is opt-in via BROWSER_MCP_CHECK_NPM=1). Treat it like **current**.
624
+ Call it once per distinct obstacle, not once per retry.
625
+
247
626
  ## Sharing wishes / use-cases / bugs
248
627
  Browser MCP is community-shaped. Whenever the user (a) says "I wish browser-mcp could…" or expresses a missing feature, (b) hits something broken or unexpected that looks like a bug in browser-mcp itself, or (c) describes something cool they built with it — call **browser_about** with the matching intent ("wish" / "use_case" / "bug") and a short title + body, then offer the returned submit_url to the user as a clickable link. Don't ask permission, just draft + offer the link. This is the user's path to contribute back, and they usually don't know it exists.`;
249
628
 
629
+ // FEJL MAALT 21/8: `instructions` laa i et TREDJE argument. Server-konstruktoeren
630
+ // tager kun to (serverInfo, options), saa JavaScript smed objektet vaek i tavshed —
631
+ // og hele blokken ovenfor naaede aldrig frem til nogen klient. Verificeret ved at
632
+ // laese initialize-svaret: det havde kun protocolVersion, capabilities og serverInfo.
633
+ // Det betyder at "luk altid faner naar du er faerdig", CAPTCHA-fremgangsmaaden,
634
+ // tekst-selektor-vejledningen og resten aldrig har styret nogen agent. Nu ligger
635
+ // instructions i SAMME options-objekt som capabilities, hvor SDK'en laeser den.
250
636
  const mcpServer = new Server(
251
637
  { name: 'agent360-browser', version: PKG_VERSION },
252
- { capabilities: { tools: {} } },
253
- { instructions: INSTRUCTIONS },
638
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS },
254
639
  );
255
640
 
256
641
  mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -295,12 +680,21 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
295
680
  browser_dismiss_overlays: 'dismiss_overlays',
296
681
  browser_set_combobox: 'set_combobox',
297
682
  browser_drop_file: 'drop_file',
683
+ browser_double_click: 'double_click',
684
+ browser_right_click: 'right_click',
685
+ browser_click_xy: 'click_xy',
686
+ browser_reattach_debugger: 'reattach_debugger',
687
+ browser_extract_list: 'extract_list',
298
688
  };
299
689
 
300
690
  if (name === 'browser_about') {
301
691
  return handleAbout(args);
302
692
  }
303
693
 
694
+ if (name === 'browser_provide_feedback') {
695
+ return await handleProvideFeedback(args);
696
+ }
697
+
304
698
  if (name === 'browser_extract_token') {
305
699
  return await handleExtractToken(args);
306
700
  }
@@ -310,8 +704,11 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
310
704
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
311
705
  }
312
706
 
707
+ // extract_list scrolls a container in a loop (up to 300 rounds × wait_ms), so the 30 s
708
+ // default would kill a long mail list mid-walk and report a partial set as complete.
313
709
  const timeout = method === 'ask_user' ? (args?.timeout || 120000) + 5000 :
314
- method === 'solve_captcha' ? 60000 : 30000;
710
+ method === 'solve_captcha' ? 60000 :
711
+ method === 'extract_list' ? 180000 : 30000;
315
712
  const result = await sendToExtension(method, args || {}, timeout);
316
713
 
317
714
  if (name === 'browser_screenshot' && result?.image) {
@@ -321,7 +718,18 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
321
718
  const base64 = result.image.replace(prefix, '');
322
719
 
323
720
  if (args && args.path) {
324
- const targetPath = resolve(process.cwd(), args.path);
721
+ // MAALT 23/8: ingen indeslutning. En sti med ../../.. skrev til
722
+ // /private/tmp/a360-udenfor/x.png. Argumenterne kommer fra en model der laeser
723
+ // FREMMEDE websider, saa en prompt-injektion paa en vilkaarlig side kunne
724
+ // overskrive en fil i brugerens hjemmemappe med PNG-bytes.
725
+ const rod = resolve(process.cwd());
726
+ const targetPath = resolve(rod, args.path);
727
+ if (targetPath !== rod && !targetPath.startsWith(rod + sep)) {
728
+ throw new Error(
729
+ `path skal ligge inden for arbejdsmappen (${rod}). ` +
730
+ `"${args.path}" peger udenfor. Brug en relativ sti uden ../.`,
731
+ );
732
+ }
325
733
  mkdirSync(dirname(targetPath), { recursive: true });
326
734
  writeFileSync(targetPath, Buffer.from(base64, 'base64'));
327
735
  return {
@@ -351,12 +759,45 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
351
759
  return response;
352
760
  } catch (err) {
353
761
  return {
354
- content: [{ type: 'text', text: `Error: ${err.message}` }],
762
+ content: [{ type: 'text', text: forklarSkaevhed(err.message) }],
355
763
  isError: true,
356
764
  };
357
765
  }
358
766
  });
359
767
 
768
+ // Naar udvidelsen er aeldre end serveren, svarer den `Unknown method: X` — og det er
769
+ // alt brugeren ser. Det sker GARANTERET: serveren kommer fra npm og opdateres straks,
770
+ // mens udvidelsen skal gennem Chrome Web Stores review paa 1-3 dage. I det vindue
771
+ // findes otte vaerktoejer i serveren som en 1.25.0-udvidelse ikke kender
772
+ // (click_xy, double_click, right_click, extract_list, reattach_debugger og de tre
773
+ // udklipsholder-vaerktoejer, alle fra v1.26.0).
774
+ //
775
+ // Serveren VED at udvidelsen er gammel: den sendte intet haandtryk. Saa i stedet for
776
+ // en gaadefuld fejl faar brugeren at vide hvorfor — og hvad de kan goere imens.
777
+ const ERSTATNINGER = {
778
+ double_click: 'kald `browser_click` to gange',
779
+ right_click: 'brug `browser_execute_script` med et contextmenu-event',
780
+ click_xy: 'brug `browser_click` med en selector',
781
+ extract_list: 'brug `browser_get_page_content` og scroll med `browser_scroll`',
782
+ reattach_debugger: 'genindlaes udvidelsen paa chrome://extensions',
783
+ };
784
+
785
+ function forklarSkaevhed(besked) {
786
+ const m = /Unknown method: ([a-z_]+)/.exec(besked || '');
787
+ if (!m) return `Error: ${besked}`;
788
+ const aktiv = activeConnection();
789
+ // Kun hvis udvidelsen faktisk er for gammel. Er den ny og metoden alligevel ukendt,
790
+ // er det en aegte fejl og skal ikke bortforklares.
791
+ if (aktiv && aktiv.version) return `Error: ${besked}`;
792
+ const alt = ERSTATNINGER[m[1]];
793
+ return `Error: browser_${m[1]} findes i denne server, men ikke i din Chrome-udvidelse.\n\n` +
794
+ 'Udvidelsen opdateres via Chrome Web Store og kan vaere 1-3 dage bagud efter en ' +
795
+ 'udgivelse — serveren opdateres med det samme via npm. Alt andet virker imens.\n' +
796
+ (alt ? `\nIndtil da: ${alt}.\n` : '') +
797
+ '\nTjek om en opdatering venter: chrome://extensions → Agent360 Browser MCP. ' +
798
+ 'Er den indlaest som "unpacked", saa koer `npx @agent360/browser-mcp install`.';
799
+ }
800
+
360
801
  const REPO_URL = 'https://github.com/Agent360dk/browser-mcp';
361
802
  const ISSUE_TEMPLATES = { wish: 'wish.yml', use_case: 'use-case.yml', bug: 'bug.yml' };
362
803
 
@@ -396,6 +837,253 @@ function handleAbout(args) {
396
837
  };
397
838
  }
398
839
 
840
+ // ── Selv-diagnose: er installationen overhovedet frisk? ─────────────────────
841
+ // Baggrund (21/8): den udgave der koerte lokalt var npm 1.25.0, mens rettelserne
842
+ // laa uudgivet i repoet. Fejlen viste sig som "sessioner opfoerer sig underligt",
843
+ // ikke som "du koerer en gammel version" — og der fandtes ingen maade at spoerge
844
+ // paa. Derfor spoerger vaerktoejet selv, foer det konkluderer noget som helst.
845
+
846
+ // Friskheds-tjek mod npm. SLUKKET SOM STANDARD siden 22/8.
847
+ //
848
+ // Produktet lover paa forsiden "nothing leaves your machine ... no telemetry", og et
849
+ // opslag i npm-registret ER et kald ud af maskinen — ogsaa selv om det kun sender et
850
+ // pakkenavn og ingen brugerdata. Loeftet vejer tungere end bekvemmeligheden, saa
851
+ // tjekket er nu opt-in: saet BROWSER_MCP_CHECK_NPM=1.
852
+ //
853
+ // Alt det der betyder mest er ren LOKAL maaling og koerer altid: er udvidelsen aeldre
854
+ // end serveren, og er der flere udvidelser forbundet paa én gang.
855
+ const TJEK_NPM = process.env.BROWSER_MCP_CHECK_NPM === '1';
856
+ let npmLatestCache = null; // { version, at }
857
+ const NPM_LATEST_TTL_MS = 10 * 60 * 1000;
858
+
859
+ function npmLatestVersion() {
860
+ if (!TJEK_NPM) return Promise.resolve(null);
861
+ if (npmLatestCache && Date.now() - npmLatestCache.at < NPM_LATEST_TTL_MS) {
862
+ return Promise.resolve(npmLatestCache.version);
863
+ }
864
+ return new Promise((resolve) => {
865
+ // Offline, bag proxy, eller npm mangler paa PATH: svar null i stedet for at fejle.
866
+ // En friskheds-kontrol maa aldrig vaere det der braekker vaerktoejet.
867
+ execFile('npm', ['view', '@agent360/browser-mcp', 'version'], { timeout: 6000 }, (err, stdout) => {
868
+ if (err) return resolve(null);
869
+ const v = String(stdout).trim();
870
+ const ok = /^\d+\.\d+\.\d+/.test(v) ? v : null;
871
+ if (ok) npmLatestCache = { version: ok, at: Date.now() };
872
+ resolve(ok);
873
+ });
874
+ });
875
+ }
876
+
877
+ // ── Lokal logbog over hver graense agenten render ind i ─────────────────────
878
+ //
879
+ // Formaalet er loekken: hver gang et vaerktoej spaerrer vejen, skal det kunne taelles og
880
+ // rettes. Logbogen ligger LOKALT og forlader ikke maskinen.
881
+ //
882
+ // Bevidst ikke auto-indsendelse til et offentligt GitHub-issue: rapporten baerer URL og
883
+ // fejltekst fra den side agenten stod paa — og det er ofte en annoncekonto, en indbakke
884
+ // eller et kundesystem. Et offentligt issue kan ikke tages tilbage.
885
+ const FEEDBACK_LOG = join(homedir(), '.browser-mcp', 'feedback.jsonl');
886
+ const setteFingeraftryk = new Set(); // samme graense logges én gang pr. serverliv
887
+
888
+ function fingeraftryk(kind, tool, what) {
889
+ // Tal, id'er og lange hex-strenge varierer fra gang til gang og maa ikke goere to ens
890
+ // haendelser forskellige.
891
+ const kerne = String(what).toLowerCase()
892
+ .replace(/\b[0-9a-f]{8,}\b/g, '#')
893
+ .replace(/\d+/g, '#')
894
+ .slice(0, 160);
895
+ return `${kind}|${tool || '-'}|${kerne}`;
896
+ }
897
+
898
+ // URL'en reduceres til oprindelse + sti. Query og fragment baerer tokens, sessions-id'er
899
+ // og soegetermer — de har intet at goere i en logbog nogen senere kopierer ind i et issue.
900
+ function afkortUrl(u) {
901
+ if (!u) return null;
902
+ try { const x = new URL(u); return x.origin + x.pathname; } catch { return '(ulaeselig url)'; }
903
+ }
904
+
905
+ function skrivTilLogbog(post) {
906
+ const fp = fingeraftryk(post.kind, post.tool, post.what_happened);
907
+ const foerste = !setteFingeraftryk.has(fp);
908
+ setteFingeraftryk.add(fp);
909
+ if (!foerste) return { logged: false, reason: 'allerede logget i denne session', fingerprint: fp };
910
+ try {
911
+ mkdirSync(dirname(FEEDBACK_LOG), { recursive: true });
912
+ appendFileSync(FEEDBACK_LOG, JSON.stringify({ ...post, fingerprint: fp }) + '\n');
913
+ return { logged: true, path: FEEDBACK_LOG, fingerprint: fp };
914
+ } catch (e) {
915
+ // En logbog der ikke kan skrives maa aldrig vaere det der braekker vaerktoejet.
916
+ return { logged: false, reason: e.message, fingerprint: fp };
917
+ }
918
+ }
919
+
920
+ async function handleProvideFeedback(args) {
921
+ const what = String(args?.what_happened || '').trim();
922
+ const kind = args?.kind || 'blocked';
923
+ const tool = args?.tool || null;
924
+ const url = args?.url || null;
925
+ const attempted = args?.attempted || null;
926
+
927
+ const npmLatest = await npmLatestVersion();
928
+ const exts = distinctExtensions();
929
+ const active = activeConnection();
930
+
931
+ const serverOutdated = npmLatest ? cmpVersion(npmLatest, PKG_VERSION) > 0 : null;
932
+ // Udvidelse og server udgives sammen under samme versionsnummer, saa en
933
+ // udvidelse der er AELDRE end serveren mangler per definition rettelser.
934
+ const extVersion = active ? active.version : null;
935
+ const extOutdated = active
936
+ ? (extVersion === null ? true : cmpVersion(PKG_VERSION, extVersion) > 0)
937
+ : null;
938
+
939
+ const findings = [];
940
+ const fix_steps = [];
941
+
942
+ if (exts.length > 1) {
943
+ const kanVaelge = exts.some(c => c.version);
944
+ findings.push(
945
+ `${exts.length} Browser MCP-udvidelser er indlaest i Chrome og forbundet til denne server samtidig ` +
946
+ `(${exts.map(c => `${c.extensionId || 'ukendt id'}${c.version ? ' v' + c.version : ' (oplyser ikke version)'}`).join(' + ')}). ` +
947
+ 'Hver af dem foerer sit eget sessions-kort og sine egne fane-grupper i den samme browser, ' +
948
+ 'saa faner kan se ud til at forsvinde og sessioner til at smelte sammen. ' +
949
+ (kanVaelge
950
+ ? `Denne server sender kun til den nyeste (${active?.extensionId}).`
951
+ : `Ingen af dem oplyser sin version, saa hvilken der styres (${active?.extensionId}) er vilkaarligt og kan skifte mellem sessioner.`),
952
+ );
953
+ fix_steps.push(
954
+ 'Aabn chrome://extensions og slaa alle Browser MCP-udvidelser fra paa naer én. ' +
955
+ 'Det skal brugeren selv goere — chrome:// kan ikke styres herfra. Behold den nyeste.',
956
+ );
957
+ }
958
+ if (!active && activePort === null) {
959
+ // Ingen port taget endnu = browseren er ikke brugt i denne chat. Der er intet
960
+ // i stykker, og et fix-skridt her ville vaere en falsk alarm — se verdict 'idle'.
961
+ findings.push('Browseren er ikke taget i brug i denne chat endnu, saa der er ingen forbindelse at maale paa. Det er ikke en fejl.');
962
+ } else if (!active) {
963
+ findings.push('Ingen Chrome-udvidelse er forbundet til denne MCP-server lige nu.');
964
+ fix_steps.push('Tjek at Chrome koerer og at udvidelsen er slaaet til paa chrome://extensions, klik derefter paa ikonet → Reconnect.');
965
+ }
966
+ if (serverOutdated) {
967
+ findings.push(`MCP-serveren koerer v${PKG_VERSION}, men npm har v${npmLatest}. Fejlen kan allerede vaere rettet.`);
968
+ fix_steps.push(`Genstart klienten — den henter selv @agent360/browser-mcp@latest (v${npmLatest}).`);
969
+ }
970
+ if (extOutdated) {
971
+ findings.push(
972
+ extVersion === null
973
+ ? `Den forbundne udvidelse er saa gammel at den ikke oplyser sin version (foer v${PKG_VERSION}). Den mangler alt hvad der er rettet siden.`
974
+ : `Udvidelsen er v${extVersion}, serveren er v${PKG_VERSION}. Udvidelsen mangler rettelser fra de mellemliggende udgaver.`,
975
+ );
976
+ // MAALT 22/8: "↻ reload" er ubrugeligt for en Chrome Web Store-bruger. Butikken
977
+ // skubber paa Googles tidsplan efter et review paa 1-3 dage — der er ingen nyere
978
+ // version at hente endnu, saa raadet foerer i ring. Serveren kan ikke se hvilken
979
+ // slags installation det er (den gamle udvidelse oplyser intet), saa begge tilfaelde
980
+ // skal staa der — og det skal siges at ventetiden er forventet, ikke en fejl.
981
+ fix_steps.push(
982
+ extVersion === null
983
+ ? 'Kommer udvidelsen fra Chrome Web Store: der er sandsynligvis en nyere version i review ' +
984
+ '(1-3 dage efter en udgivelse). ↻ reload henter den IKKE foer Google har godkendt — ' +
985
+ 'det er forventet og gaar over af sig selv. Alt andet virker imens. ' +
986
+ 'Er den indlaest som "unpacked": koer `npx @agent360/browser-mcp install` og derefter ' +
987
+ 'chrome://extensions → Agent360 Browser MCP → ↻ reload.'
988
+ : 'Opdatér udvidelsen: chrome://extensions → Agent360 Browser MCP → ↻ reload. ' +
989
+ 'Er den indlaest som "unpacked", saa koer `npx @agent360/browser-mcp install` foerst.',
990
+ );
991
+ }
992
+
993
+ // ── "ingen forbindelse" og "ingen port endnu" er IKKE det samme ────────────
994
+ //
995
+ // FUNDET AF REVIEW 7/9. Foer porten blev doven, bandt hver server ved opstart, saa
996
+ // udvidelsen var altid forbundet naar dette vaerktoej blev kaldt — og `!active`
997
+ // betoed derfor paalideligt "udvidelsen kan ikke naas". Nu binder en chat foerst en
998
+ // port naar den bruger browseren, saa en HELT SUND chat der ikke har roert den kan
999
+ // staa uden forbindelse. Uden det her skel fik den `disconnected` + fix_steps der
1000
+ // bad brugeren geninstallere — og INSTRUCTIONS beder agenten viderebringe dem.
1001
+ // Vi ville altsaa fortaelle folk at deres installation var i stykker, fordi vi selv
1002
+ // endnu ikke havde aabnet doeren.
1003
+ //
1004
+ // Loesningen er ikke at binde en port her (et diagnose-vaerktoej skal ikke aendre
1005
+ // tilstand for at kunne maale den) — det er at sige praecis hvad der er tilfaeldet.
1006
+ const ingenPortEndnu = activePort === null;
1007
+ const verdict =
1008
+ exts.length > 1 ? 'conflict'
1009
+ : (serverOutdated || extOutdated) ? 'outdated'
1010
+ : (!active && ingenPortEndnu) ? 'idle'
1011
+ : !active ? 'disconnected'
1012
+ : (npmLatest === null ? 'unknown' : 'current');
1013
+
1014
+ const environment = {
1015
+ mcp_server_version: PKG_VERSION,
1016
+ npm_latest_version: npmLatest,
1017
+ server_up_to_date: serverOutdated === null ? null : !serverOutdated,
1018
+ extensions_connected: exts.map(c => ({
1019
+ name: c.name,
1020
+ version: c.version,
1021
+ extension_id: c.extensionId,
1022
+ active: c === active,
1023
+ })),
1024
+ extension_up_to_date: extOutdated === null ? null : !extOutdated,
1025
+ ws_port: activePort,
1026
+ node: process.version,
1027
+ platform: `${process.platform} ${process.arch}`,
1028
+ };
1029
+
1030
+ const issueBody = [
1031
+ what && `**What happened**\n${what}`,
1032
+ tool && `\n**Tool**: \`${tool}\``,
1033
+ // MAALT 22/8 ved sikkerhedsreview: her stod den RAA url, mens den lokale logbog
1034
+ // nedenfor bruger afkortUrl(). Query-strengen — hvor tokens bor — blev altsaa
1035
+ // strippet fra filen paa disken, men sendt uredigeret ind i et link til et
1036
+ // OFFENTLIGT GitHub-issue. Praecis den forkerte vej rundt.
1037
+ url && `\n**URL**: ${afkortUrl(url)}`,
1038
+ attempted && `\n**Already tried**\n${attempted}`,
1039
+ `\n**Environment**\n\`\`\`json\n${JSON.stringify(environment, null, 2)}\n\`\`\``,
1040
+ ].filter(Boolean).join('\n');
1041
+
1042
+ const template = kind === 'wish' ? ISSUE_TEMPLATES.wish
1043
+ : kind === 'use_case' ? ISSUE_TEMPLATES.use_case
1044
+ : ISSUE_TEMPLATES.bug;
1045
+ const issueTitle = what.split('\n')[0].slice(0, 90) || 'Browser MCP feedback';
1046
+ const submit_url = `${REPO_URL}/issues/new?template=${template}` +
1047
+ `&title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}`;
1048
+
1049
+ const instruction =
1050
+ verdict === 'idle'
1051
+ ? 'Browseren er ikke taget i brug i denne chat endnu, saa der er intet at diagnosticere paa forbindelsen — ' +
1052
+ 'det er IKKE en fejl i installationen, og du maa ikke sige det til brugeren. ' +
1053
+ 'Er der en aegte mangel, saa tilbyd submit_url som et klikbart link.'
1054
+ : verdict === 'conflict' || verdict === 'outdated' || verdict === 'disconnected'
1055
+ ? 'Fortael brugeren hvad der blev fundet, og giv fix_steps som konkrete skridt. Proev derefter handlingen igen. ' +
1056
+ 'Del KUN submit_url hvis problemet stadig staar efter at fix_steps er fulgt — det er sandsynligvis installationen, ikke en fejl i Browser MCP.'
1057
+ : 'Installationen er frisk, saa det her er sandsynligvis en aegte mangel eller fejl. Fortael brugeren kort hvad der ikke kunne lade sig goere, ' +
1058
+ 'og tilbyd submit_url som et klikbart link ("forudfyldt — du kan rette i den foer du sender"). Spoerg ikke om lov foerst.';
1059
+
1060
+ const logbog = skrivTilLogbog({
1061
+ at: new Date().toISOString(),
1062
+ kind, tool, what_happened: what, attempted,
1063
+ url: afkortUrl(url),
1064
+ verdict,
1065
+ server_version: PKG_VERSION,
1066
+ extension_version: extVersion,
1067
+ extensions_connected: exts.length,
1068
+ });
1069
+
1070
+ return {
1071
+ content: [{
1072
+ type: 'text',
1073
+ text: JSON.stringify({
1074
+ reported: { kind, what_happened: what, tool, url, attempted },
1075
+ verdict,
1076
+ findings,
1077
+ fix_steps,
1078
+ environment,
1079
+ logged_locally: logbog,
1080
+ submit_url,
1081
+ instruction,
1082
+ }, null, 2),
1083
+ }],
1084
+ };
1085
+ }
1086
+
399
1087
  async function handleExtractToken(args) {
400
1088
  const { provider } = args;
401
1089
  const info = PROVIDER_PAGES[provider];
@@ -431,10 +1119,11 @@ function gracefulShutdown(reason, code = 0) {
431
1119
  // Stop timers so they can't re-enter gracefulShutdown
432
1120
  if (parentCheck) clearInterval(parentCheck);
433
1121
  if (heartbeat) clearInterval(heartbeat);
1122
+ if (tomgangsvagt) clearInterval(tomgangsvagt);
434
1123
 
435
1124
  // Close WS with explicit close-frame so extension's onclose handler fires
436
- if (extensionSocket && extensionSocket.readyState === 1) {
437
- try { extensionSocket.close(1000, 'mcp-shutdown'); } catch {}
1125
+ for (const c of liveConnections()) {
1126
+ try { c.ws.close(1000, 'mcp-shutdown'); } catch {}
438
1127
  }
439
1128
  if (wss) try { wss.close(); } catch {}
440
1129
 
@@ -447,19 +1136,100 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
447
1136
  process.on('exit', () => {
448
1137
  // Safety net for direct process.exit calls that bypass gracefulShutdown
449
1138
  if (wss) try { wss.close(); } catch {}
450
- if (extensionSocket) try { extensionSocket.close(); } catch {}
1139
+ for (const c of connections) try { c.ws.close(); } catch {}
451
1140
  });
452
1141
 
453
- // Detect Claude Code exit check if parent process is still alive
454
- // stdin.on('end') doesn't work because MCP SDK's StdioServerTransport owns stdin
1142
+ // ── Naar lukker serveren sin port? (MAALT 22/8) ──────────────────────────────
1143
+ //
1144
+ // Symptomet: tyve porte optaget, og ingen chats i live bag dem. Aarsagen var at
1145
+ // tjekket kiggede paa den FORKERTE proces. Kaeden er nemlig ikke to led, men fire:
1146
+ //
1147
+ // Claude Code → wrapper → npm exec → denne server
1148
+ //
1149
+ // `process.ppid` er `npm exec`, ikke Claude Code. Doer chatten, kan `npm exec`
1150
+ // blive haengende som foraeldreloes — og saa ser tjekket en levende foraelder for
1151
+ // evigt. Porten blev holdt i op til fire timer (idle-graensen), og med flere
1152
+ // forladte chats loeb spaendet fuldt.
1153
+ //
1154
+ // Rettelsen er at tjekke HELE kaeden op til roden, ikke kun naermeste led. Doer et
1155
+ // vilkaarligt led, er forbindelsen til den chat der ejer os brudt, og saa er vi
1156
+ // foraeldreloese uanset om vores naermeste foraelder stadig aander.
1157
+ //
1158
+ // Kaeden hentes én gang ved opstart (én ps-kommando), og derefter koster tjekket
1159
+ // kun et signal 0 pr. led hvert 5. sekund. Kan kaeden ikke laeses (Windows, eller
1160
+ // ps mangler), falder vi tilbage til det gamle enkelt-tjek — daarligere, men aldrig
1161
+ // vaerre end foer.
1162
+
1163
+ // ps-opslaget bor her (det er en sideeffekt); selve kaede-logikken og doeds-dommen
1164
+ // ligger i vagt.js, saa de kan koeres i en test uden at starte en server.
1165
+ function laesPpid(pid) {
1166
+ try {
1167
+ const ud = execSync(`ps -o ppid= -p ${pid}`, {
1168
+ encoding: 'utf8',
1169
+ stdio: ['ignore', 'pipe', 'ignore'],
1170
+ timeout: 2000, // et haengende ps ville ellers blokere hele opstarten synkront
1171
+ });
1172
+ return Number(String(ud).trim());
1173
+ } catch {
1174
+ return null;
1175
+ }
1176
+ }
1177
+
455
1178
  const parentPid = process.ppid;
1179
+
1180
+ // MAALT 22/8: doer den naermeste foraelder FOER serveren er bootet (~2 sek node+SDK),
1181
+ // laeser process.ppid vaerdien 1 — altsaa launchd. Kaeden blev saa [1], og pid 1 er
1182
+ // baade udoedelig og ejet af root, saa vagten var enten inert eller draebte os selv
1183
+ // paa EPERM. En kaede der kun bestaar af pid 1 vogter ingenting og skal ikke bruges.
1184
+ let vagtKaede = parentPid > 1 ? [parentPid] : [];
1185
+ try {
1186
+ const k = forfaedreKaede(parentPid, laesPpid).filter((x) => x > 1);
1187
+ if (k.length) vagtKaede = k;
1188
+ } catch {}
1189
+ if (!vagtKaede.length) {
1190
+ process.stderr.write('[MCP] ingen brugbar foraelder-kaede (ppid=' + parentPid +
1191
+ ') — falder tilbage paa idle-graensen alene\n');
1192
+ }
1193
+ process.stderr.write(`[MCP] vagt-kaede: ${vagtKaede.join(' → ')}\n`);
1194
+
456
1195
  parentCheck = setInterval(() => {
1196
+ const doede = vagtKaede.filter((pid) => ledErDoedt(pid));
1197
+ if (!doede.length) return;
1198
+
1199
+ // ── Et doedt led betyder ikke automatisk at chatten er vaek (MAALT 23/8) ──────
1200
+ //
1201
+ // Kaeden blev frosset ved opstart. Men et MELLEMLED kan afslutte helt normalt
1202
+ // mens ejeren koerer videre — maalt to gange paa denne maskine, hvor kaeden gaar
1203
+ // npm exec → wrapper → claude → Code Helper (Plugin) → Code. Et forbigaaende led
1204
+ // der lukkede pænt udloeste "chatten bag denne server er vaek", mens chatten var
1205
+ // uroert. Og risikoen er ensrettet vaerre end 1.25.0, som vogtede ét pid: nu er
1206
+ // hvert af 5-6 led en ny doedsaarsag.
1207
+ //
1208
+ // Derfor genlaeses kaeden foerst naar noget SER doedt ud. Kan vi stadig gaa fra
1209
+ // vores egen foraelder op til en rod, er vi ikke foraeldreloese — vi er bare blevet
1210
+ // reparented, og den nye kaede overtager. Kun naar den vej ogsaa er vaek, lukker vi.
1211
+ //
1212
+ // Prisen er nul i normal drift: genlaesningen koerer kun i det tik hvor et led er
1213
+ // forsvundet, ikke hvert 5. sekund.
1214
+ let frisk = [];
457
1215
  try {
458
- process.kill(parentPid, 0); // signal 0 = check if process exists
459
- } catch {
460
- gracefulShutdown(`Parent process ${parentPid} died`);
1216
+ frisk = forfaedreKaede(process.ppid, laesPpid).filter((x) => x > 1 && !ledErDoedt(x));
1217
+ } catch {}
1218
+
1219
+ if (frisk.length) {
1220
+ process.stderr.write(
1221
+ `[MCP] led ${doede.join(', ')} er vaek, men kaeden gaar stadig op: ` +
1222
+ `${frisk.join(' → ')} — fortsaetter\n`,
1223
+ );
1224
+ vagtKaede = frisk;
1225
+ return;
461
1226
  }
462
- }, 5000); // check every 5 seconds
1227
+
1228
+ gracefulShutdown(
1229
+ `Proces ${doede[0]} i kaeden doede, og der er ingen levende vej op — ` +
1230
+ 'chatten bag denne server er vaek',
1231
+ );
1232
+ }, 5000); // hvert 5. sekund
463
1233
 
464
1234
  // Also listen for stdin close as backup
465
1235
  process.stdin.on('end', () => gracefulShutdown('stdin closed'));