@empir3/empir3-bridge 0.3.21

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
package/src/bridge.ts ADDED
@@ -0,0 +1,2444 @@
1
+ /**
2
+ * Empir3 Browser Bridge — CDP Server
3
+ *
4
+ * Empir3's own CDP bridge. Launches Chrome with remote debugging,
5
+ * exposes the same HTTP API surface on the same port (default 9867).
6
+ *
7
+ * Zero external dependencies — uses Node built-ins + raw CDP WebSocket.
8
+ *
9
+ * Endpoints:
10
+ * GET /health — readiness check
11
+ * GET /screenshot — viewport capture (JPEG)
12
+ * GET /snapshot — accessibility tree element refs
13
+ * GET /text — extract readable page text
14
+ * GET /tabs — list open tabs
15
+ * POST /navigate — load URL
16
+ * POST /action — click/type/press/scroll by ref
17
+ * POST /evaluate — run JavaScript
18
+ * POST /cookies — set cookies
19
+ * GET /welcome — Empir3-branded splash page
20
+ */
21
+
22
+ import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http';
23
+ import { spawn, ChildProcess } from 'child_process';
24
+ import { WebSocket } from 'ws';
25
+ import { join, resolve } from 'path';
26
+ import { mkdirSync, existsSync, readFileSync } from 'fs';
27
+
28
+ // ─── Config ──────────────────────────────────────────────────
29
+
30
+ const PORT = parseInt(process.env.BRIDGE_PORT || '9867');
31
+ const WRAPPER_PORT = parseInt(process.env.PW_PORT || process.env.EMPIR3_PW_PORT || '3006');
32
+ const HOST = process.env.EMPIR3_BRIDGE_HOST || '127.0.0.1';
33
+ const HEADLESS = process.env.BRIDGE_HEADLESS === 'true';
34
+ const CDP_PORT = parseInt(process.env.CDP_PORT || '9222');
35
+ const CHROME_PATHS = [
36
+ process.env.CHROME_PATH,
37
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
38
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
39
+ '/usr/bin/google-chrome',
40
+ '/usr/bin/chromium-browser',
41
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
42
+ ].filter(Boolean) as string[];
43
+
44
+ const PROFILE_DIR = process.env.BRIDGE_PROFILE || process.env.EMPIR3_BRIDGE_CHROME_PROFILE || join(
45
+ process.env.HOME || process.env.USERPROFILE || '.',
46
+ '.empir3-bridge', 'profile'
47
+ );
48
+
49
+ const SESSION_TOKEN = process.env.BRIDGE_TOKEN || '';
50
+ const NAV_TIMEOUT = parseInt(process.env.BRIDGE_NAV_TIMEOUT || '60') * 1000;
51
+ const CDP_COMMAND_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_COMMAND_TIMEOUT_MS || '10000');
52
+ const CDP_LIVENESS_MAX_AGE_MS = parseInt(process.env.BRIDGE_CDP_LIVENESS_MAX_AGE_MS || '2000');
53
+ const CDP_LIVENESS_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_LIVENESS_TIMEOUT_MS || '2500');
54
+ const CDP_PERMISSION_TIMEOUT_MS = parseInt(process.env.BRIDGE_CDP_PERMISSION_TIMEOUT_MS || '500');
55
+ const CHROME_LAUNCH_TIMEOUT_MS = Math.max(
56
+ 15000,
57
+ Number.parseInt(process.env.CHROME_LAUNCH_TIMEOUT_MS || process.env.BRIDGE_CHROME_LAUNCH_TIMEOUT_MS || '90000', 10) || 90000,
58
+ );
59
+
60
+ // ─── State ───────────────────────────────────────────────────
61
+
62
+ let chromeProcess: ChildProcess | null = null;
63
+ let cdpWs: WebSocket | null = null;
64
+ let cdpId = 1;
65
+ const cdpCallbacks = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; ws: WebSocket }>();
66
+ let currentTargetId = '';
67
+ let currentSessionId = '';
68
+ let connected = false;
69
+
70
+ // Target tracking — detect new tabs and inject scripts
71
+ const knownTargets = new Map<string, string>(); // targetId → last known URL
72
+ let autoInjectScript = ''; // script to inject into every new page target
73
+ let targetPollTimer: ReturnType<typeof setInterval> | null = null;
74
+
75
+ // Browser-level CDP connection for target discovery
76
+ let browserWs: WebSocket | null = null;
77
+ let browserCdpId = 100000; // offset from page-level IDs to avoid collision
78
+ const browserCallbacks = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
79
+ let browserWsConnecting = false;
80
+ let browserReconnectTimer: ReturnType<typeof setTimeout> | null = null;
81
+ let launchPromise: Promise<void> | null = null;
82
+ let lastCdpLivenessAt = 0;
83
+ let chromeEverStarted = false;
84
+ let chromeExitCode: number | null = null;
85
+ let chromeClosedByUser = false;
86
+ let shuttingDown = false;
87
+ let cdpCommandQueue: Promise<void> = Promise.resolve();
88
+
89
+ // Element ref tracking
90
+ let refMap = new Map<string, number>(); // ref string → CDP nodeId
91
+ let refCounter = 0;
92
+
93
+ // --fresh state — only wipe storage on the FIRST CDP connect of this Chrome
94
+ // launch. CDP can reconnect mid-session (Chrome reload, target switch); we
95
+ // don't want each reconnect re-wiping the user's just-typed-in cookies.
96
+ let freshConsumed = false;
97
+
98
+ // ─── Chrome Launcher ─────────────────────────────────────────
99
+
100
+ function findChrome(): string {
101
+ for (const p of CHROME_PATHS) {
102
+ if (existsSync(p)) return p;
103
+ }
104
+ throw new Error('Chrome not found. Set CHROME_PATH env var.');
105
+ }
106
+
107
+ function chromeStatus(): 'running' | 'exited' | 'not-started' {
108
+ if (chromeProcess) return 'running';
109
+ return chromeEverStarted ? 'exited' : 'not-started';
110
+ }
111
+
112
+ function closedBrowserError(): Error {
113
+ return new Error('Bridge browser is closed. Use browser_navigate or Open Bridge to reopen it.');
114
+ }
115
+
116
+ function markChromeClosedByUser(reason: string) {
117
+ if (!chromeClosedByUser) {
118
+ console.log(`[Empir3 Bridge] Bridge browser closed by user (${reason})`);
119
+ }
120
+ chromeClosedByUser = true;
121
+ connected = false;
122
+ lastCdpLivenessAt = 0;
123
+ currentTargetId = '';
124
+ knownTargets.clear();
125
+ if (cdpWs) { try { cdpWs.close(); } catch {} }
126
+ cdpWs = null;
127
+ stopTargetPolling();
128
+ if (browserReconnectTimer) {
129
+ clearTimeout(browserReconnectTimer);
130
+ browserReconnectTimer = null;
131
+ }
132
+ if (browserWs) { try { browserWs.close(); } catch {} }
133
+ browserWs = null;
134
+ }
135
+
136
+ // Guards against stacking concurrent close-confirmation checks (pollTargets can
137
+ // fire one every 500ms while a check is still mid-flight).
138
+ let closeCheckInFlight = false;
139
+
140
+ // A single empty /json read is NOT proof the user closed the browser: during a
141
+ // cross-process navigation Chrome briefly destroys the old page target before the
142
+ // new one appears, and a refresh momentarily yields zero/changing targets. Latch
143
+ // "closed by user" only after several consecutive confirmations (~500ms) while the
144
+ // Chrome PROCESS is still alive — a genuine close also ends the process and is
145
+ // caught separately by the chromeProcess 'exit' handler (markChromeClosedByUser
146
+ // 'process exit'). This kills the post-refresh ONLINE->OFFLINE flap, where one
147
+ // transient empty read used to latch the bridge "disconnected" permanently.
148
+ async function markClosedIfNoPageTargets(reason: string): Promise<void> {
149
+ if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
150
+ if (closeCheckInFlight) return;
151
+ closeCheckInFlight = true;
152
+ try {
153
+ for (let attempt = 0; attempt < 3; attempt++) {
154
+ if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
155
+ try {
156
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
157
+ if (targets.some((t: any) => t.type === 'page')) return; // a page reappeared — not closed
158
+ } catch {
159
+ return; // /json hiccup — inconclusive; never latch closed on an error
160
+ }
161
+ if (attempt < 2) await sleep(250);
162
+ }
163
+ if (!chromeProcess || chromeClosedByUser || shuttingDown) return;
164
+ markChromeClosedByUser(reason);
165
+ } finally {
166
+ closeCheckInFlight = false;
167
+ }
168
+ }
169
+
170
+ async function terminateClosedChromeForRelaunch(): Promise<void> {
171
+ const proc = chromeProcess;
172
+ if (!proc) return;
173
+ try { proc.kill(); } catch {}
174
+ const deadline = Date.now() + 3000;
175
+ while (chromeProcess === proc && Date.now() < deadline) {
176
+ await sleep(100);
177
+ }
178
+ if (chromeProcess === proc) {
179
+ chromeProcess = null;
180
+ }
181
+ }
182
+
183
+ async function waitForChromeCDP(timeoutMs = CHROME_LAUNCH_TIMEOUT_MS): Promise<void> {
184
+ const start = Date.now();
185
+ let lastError: any = null;
186
+ while (Date.now() - start < timeoutMs) {
187
+ if (!chromeProcess && chromeEverStarted) {
188
+ const suffix = chromeExitCode == null ? '' : ` (code ${chromeExitCode})`;
189
+ throw new Error(`Chrome exited before CDP connected${suffix}`);
190
+ }
191
+ await sleep(500);
192
+ try {
193
+ await connectCDP();
194
+ return;
195
+ } catch (e) {
196
+ lastError = e;
197
+ }
198
+ }
199
+ const detail = lastError?.message ? `: ${lastError.message}` : '';
200
+ throw new Error(`Chrome did not expose CDP within ${Math.round(timeoutMs / 1000)}s${detail}`);
201
+ }
202
+
203
+ async function launchChrome(timeoutMs = CHROME_LAUNCH_TIMEOUT_MS): Promise<void> {
204
+ if (chromeProcess) {
205
+ await waitForChromeCDP(timeoutMs);
206
+ return;
207
+ }
208
+
209
+ const chromePath = findChrome();
210
+
211
+ // Ensure profile directory exists
212
+ mkdirSync(PROFILE_DIR, { recursive: true });
213
+
214
+ // Clear session restore data to prevent Chrome from reloading previous tabs
215
+ const sessionsDir = join(PROFILE_DIR, 'Default', 'Sessions');
216
+ try {
217
+ if (existsSync(sessionsDir)) {
218
+ const { readdirSync, unlinkSync } = require('fs');
219
+ for (const f of readdirSync(sessionsDir)) {
220
+ try { unlinkSync(join(sessionsDir, f)); } catch {}
221
+ }
222
+ }
223
+ } catch {}
224
+
225
+ const args = [
226
+ `--remote-debugging-port=${CDP_PORT}`,
227
+ '--remote-debugging-address=127.0.0.1',
228
+ `--user-data-dir=${PROFILE_DIR}`,
229
+ '--no-first-run',
230
+ '--no-default-browser-check',
231
+ '--disable-background-networking',
232
+ '--disable-default-apps',
233
+ '--disable-sync',
234
+ '--disable-translate',
235
+ '--metrics-recording-only',
236
+ '--safebrowsing-disable-auto-update',
237
+ '--disable-session-crashed-bubble',
238
+ '--restore-last-session=false',
239
+ '--hide-crash-restore-bubble',
240
+ '--disable-popup-blocking',
241
+ '--disable-notifications',
242
+ '--autoplay-policy=no-user-gesture-required',
243
+ '--deny-permission-prompts',
244
+ '--disable-permissions-api',
245
+ ];
246
+
247
+ if (HEADLESS) {
248
+ args.push('--headless=new');
249
+ }
250
+
251
+ // Start with welcome page. If a per-launch nonce was provided, stamp it on
252
+ // the URL — page scripts read it and use /api/identity to pick the right
253
+ // wrapper port when multiple bridges are running.
254
+ const nonce = process.env.EMPIR3_BRIDGE_NONCE || '';
255
+ const welcomePort = String(WRAPPER_PORT || PORT);
256
+ args.push(nonce
257
+ ? `http://localhost:${welcomePort}/welcome?bridgeNonce=${encodeURIComponent(nonce)}`
258
+ : `http://localhost:${welcomePort}/welcome`);
259
+
260
+ console.log(`[Empir3 Bridge] Launching Chrome: ${chromePath}`);
261
+ console.log(`[Empir3 Bridge] Chrome profile: ${PROFILE_DIR}`);
262
+ chromeEverStarted = true;
263
+ chromeExitCode = null;
264
+ chromeClosedByUser = false;
265
+ chromeProcess = spawn(chromePath, args, {
266
+ stdio: ['ignore', 'pipe', 'pipe'],
267
+ detached: false,
268
+ });
269
+
270
+ chromeProcess.stderr?.on('data', (d: Buffer) => {
271
+ const line = d.toString().trim();
272
+ if (line && !line.includes('DevTools listening')) {
273
+ // Suppress noisy Chrome stderr
274
+ }
275
+ });
276
+
277
+ chromeProcess.on('exit', (code) => {
278
+ console.log(`[Empir3 Bridge] Chrome exited (code ${code})`);
279
+ chromeExitCode = code;
280
+ if (!shuttingDown) {
281
+ markChromeClosedByUser('process exit');
282
+ } else {
283
+ connected = false;
284
+ cdpWs = null;
285
+ stopTargetPolling();
286
+ if (browserReconnectTimer) {
287
+ clearTimeout(browserReconnectTimer);
288
+ browserReconnectTimer = null;
289
+ }
290
+ if (browserWs) { try { browserWs.close(); } catch {} }
291
+ browserWs = null;
292
+ }
293
+ chromeProcess = null;
294
+ });
295
+
296
+ await waitForChromeCDP(timeoutMs);
297
+ }
298
+
299
+ async function ensureChromeReady(opts: { allowRelaunch?: boolean; launchTimeoutMs?: number } = {}): Promise<void> {
300
+ const allowRelaunch = opts.allowRelaunch === true;
301
+ const launchTimeoutMs = Math.max(1000, opts.launchTimeoutMs || CHROME_LAUNCH_TIMEOUT_MS);
302
+ if (chromeClosedByUser && !allowRelaunch) {
303
+ throw closedBrowserError();
304
+ }
305
+ if (await hasReachablePageTarget()) return;
306
+ if (chromeClosedByUser && allowRelaunch && chromeProcess) {
307
+ await terminateClosedChromeForRelaunch();
308
+ }
309
+ if (chromeClosedByUser && !allowRelaunch) {
310
+ throw closedBrowserError();
311
+ }
312
+ if (launchPromise) {
313
+ await launchPromise;
314
+ if (await hasReachablePageTarget()) return;
315
+ if (chromeClosedByUser && !allowRelaunch) {
316
+ throw closedBrowserError();
317
+ }
318
+ }
319
+
320
+ launchPromise = (async () => {
321
+ if (chromeProcess) {
322
+ try {
323
+ await waitForChromeCDP(launchTimeoutMs);
324
+ startTargetPolling();
325
+ connectBrowserWs().catch(() => {});
326
+ return;
327
+ } catch (e: any) {
328
+ if (chromeProcess) throw e;
329
+ console.warn(`[Empir3 Bridge] Existing Chrome exited during startup: ${e?.message || e}`);
330
+ await sleep(800);
331
+ }
332
+ }
333
+
334
+ if (chromeClosedByUser && !allowRelaunch) {
335
+ throw closedBrowserError();
336
+ }
337
+ await launchChrome(launchTimeoutMs);
338
+ startTargetPolling();
339
+ connectBrowserWs().catch(() => {});
340
+ })();
341
+
342
+ try {
343
+ await launchPromise;
344
+ } finally {
345
+ launchPromise = null;
346
+ }
347
+ }
348
+
349
+ async function showChromeWindow(preferredUrl?: string): Promise<string> {
350
+ let href = preferredUrl || `http://localhost:${WRAPPER_PORT || PORT}/welcome`;
351
+
352
+ try {
353
+ await ensureChromeReady({ allowRelaunch: true, launchTimeoutMs: 5000 });
354
+ } catch (e: any) {
355
+ if (chromeProcess) {
356
+ console.warn(`[Empir3 Bridge] Open Bridge launched Chrome, but CDP was not ready yet: ${e?.message || e}`);
357
+ return href;
358
+ }
359
+ throw e;
360
+ }
361
+
362
+ if (!preferredUrl) {
363
+ href = '';
364
+ }
365
+ if (!href) {
366
+ try {
367
+ const current = await cdpEvaluate('location.href', 1000);
368
+ href = typeof current === 'string' && current ? current : '';
369
+ } catch {}
370
+ }
371
+ if (!href || href === 'about:blank') href = `http://localhost:${WRAPPER_PORT || PORT}/welcome`;
372
+
373
+ try {
374
+ const info = await cdpSend('Browser.getWindowForTarget', { targetId: currentTargetId }, 1000);
375
+ if (info?.windowId) {
376
+ await cdpSend('Browser.setWindowBounds', {
377
+ windowId: info.windowId,
378
+ bounds: { windowState: 'normal' },
379
+ }, 1000);
380
+ }
381
+ } catch {}
382
+
383
+ try { await cdpSend('Page.bringToFront', {}, 1000); } catch {}
384
+
385
+ try {
386
+ const current = await cdpEvaluate('location.href', 1000);
387
+ if (!current || current === 'about:blank') {
388
+ await cdpNavigate(href);
389
+ }
390
+ } catch {
391
+ await cdpNavigate(href);
392
+ }
393
+
394
+ try { await cdpSend('Page.bringToFront', {}, 1000); } catch {}
395
+ return href;
396
+ }
397
+
398
+ // ─── CDP Connection ──────────────────────────────────────────
399
+
400
+ /**
401
+ * Pick the best initial CDP target. The naive "just take pages[0]" path
402
+ * silently binds the bridge to a chrome:// or chrome-extension:// tab when
403
+ * one happens to be active — e.g. if chrome://extensions/ or another internal
404
+ * page is in the foreground. Once attached there, every navigate / evaluate
405
+ * fails with "Not connected" because content scripts can't reach internal pages.
406
+ *
407
+ * Strategy:
408
+ * 1. Prefer an http(s) page if any is open
409
+ * 2. Fall back to an existing about:blank
410
+ * 3. Otherwise PUT /json/new?about:blank and use that
411
+ * 4. As a last resort, return whatever was first (better than throwing)
412
+ */
413
+ async function pickInitialTarget(): Promise<any> {
414
+ let res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
415
+ let pages = res.filter((t: any) => t.type === 'page');
416
+ if (pages.length === 0) throw new Error('No page targets');
417
+
418
+ // 1. Prefer an http/https tab
419
+ const httpTab = pages.find((t: any) => /^https?:/i.test(t.url));
420
+ if (httpTab) return httpTab;
421
+
422
+ // 2. Fall back to about:blank
423
+ const blankTab = pages.find((t: any) => t.url === 'about:blank' || t.url === '');
424
+ if (blankTab) return blankTab;
425
+
426
+ // 3. Every visible tab is a trap (chrome://extensions/, devtools://, etc).
427
+ // Open a fresh about:blank via /json/new and re-poll.
428
+ try {
429
+ await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json/new?about:blank`, 'PUT');
430
+ // Tiny wait so Chrome registers the new target before we re-list
431
+ await sleep(150);
432
+ res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
433
+ pages = res.filter((t: any) => t.type === 'page');
434
+ const fresh = pages.find((t: any) => t.url === 'about:blank' || t.url === '');
435
+ if (fresh) {
436
+ console.log('[Empir3 Bridge] All existing tabs were chrome:// — opened fresh about:blank');
437
+ return fresh;
438
+ }
439
+ } catch (e: any) {
440
+ console.log(`[Empir3 Bridge] /json/new failed: ${e.message} — falling back to first target`);
441
+ }
442
+
443
+ // 4. Last resort: use whatever was first. Better than throwing.
444
+ return pages[0];
445
+ }
446
+
447
+ async function connectCDP(): Promise<void> {
448
+ const target = await pickInitialTarget();
449
+ currentTargetId = target.id;
450
+
451
+ if (cdpWs) {
452
+ cdpWs.close();
453
+ }
454
+
455
+ return new Promise((resolve, reject) => {
456
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
457
+ let settled = false;
458
+ const timer = setTimeout(() => fail(new Error('CDP WebSocket timeout')), 5000);
459
+ const fail = (e: Error) => {
460
+ if (settled) return;
461
+ settled = true;
462
+ clearTimeout(timer);
463
+ if (cdpWs === ws) {
464
+ connected = false;
465
+ cdpWs = null;
466
+ lastCdpLivenessAt = 0;
467
+ }
468
+ try { ws.close(); } catch {}
469
+ reject(e);
470
+ };
471
+ const done = () => {
472
+ if (settled) return;
473
+ settled = true;
474
+ clearTimeout(timer);
475
+ resolve();
476
+ };
477
+
478
+ ws.on('open', async () => {
479
+ cdpWs = ws;
480
+ connected = true;
481
+ lastCdpLivenessAt = 0;
482
+ console.log(`[Empir3 Bridge] CDP connected to: ${target.url}`);
483
+
484
+ if (!(await verifyCdpConnection())) {
485
+ fail(new Error('CDP liveness check failed'));
486
+ return;
487
+ }
488
+
489
+ if (process.env.BRIDGE_AUTO_DENY_PERMISSIONS === '1') {
490
+ // Permission setup is opt-in because Browser.setPermission can wedge page CDP sockets.
491
+ setTimeout(() => {
492
+ autoDenyPermissions().catch(() => {});
493
+ }, 0);
494
+ }
495
+
496
+ if (process.env.BRIDGE_LEGACY_BLOCKING_PERMISSION_DENY === '1') {
497
+ // Legacy blocking permission path kept only for targeted debugging.
498
+ try {
499
+ const perms = ['geolocation','notifications','midi','midi-sysex','clipboard-read',
500
+ 'clipboard-write','camera','microphone','background-sync','ambient-light-sensor',
501
+ 'accelerometer','gyroscope','magnetometer','accessibility-events','payment-handler',
502
+ 'idle-detection','storage-access','window-management'];
503
+ for (const name of perms) {
504
+ try {
505
+ await cdpSend('Browser.setPermission', {
506
+ permission: { name },
507
+ setting: 'denied',
508
+ });
509
+ } catch {} // some permissions may not be supported — ignore
510
+ }
511
+ console.log('[Empir3 Bridge] Auto-denied all permission prompts');
512
+ } catch {}
513
+ }
514
+
515
+ // --fresh from launcher: wipe cookies + localStorage + IndexedDB across
516
+ // ALL origins. Runs once per Chrome launch (not per CDP reconnect — see
517
+ // freshConsumed below). Preserves the profile dir / extensions / settings.
518
+ if (process.env.EMPIR3_BRIDGE_FRESH === '1' && !freshConsumed) {
519
+ await wipeAllStorage();
520
+ freshConsumed = true;
521
+ }
522
+
523
+ done();
524
+ });
525
+
526
+ ws.on('message', (data: Buffer) => {
527
+ try {
528
+ const msg = JSON.parse(data.toString());
529
+ if (msg.id && cdpCallbacks.has(msg.id)) {
530
+ const cb = cdpCallbacks.get(msg.id)!;
531
+ cdpCallbacks.delete(msg.id);
532
+ if (msg.error) {
533
+ cb.reject(new Error(msg.error.message));
534
+ } else {
535
+ lastCdpLivenessAt = Date.now();
536
+ cb.resolve(msg.result);
537
+ }
538
+ }
539
+ } catch {}
540
+ });
541
+
542
+ ws.on('close', () => {
543
+ if (cdpWs === ws) {
544
+ connected = false;
545
+ cdpWs = null;
546
+ lastCdpLivenessAt = 0;
547
+ }
548
+ });
549
+
550
+ ws.on('error', (e) => {
551
+ fail(e);
552
+ });
553
+ });
554
+ }
555
+
556
+ async function switchToTarget(targetId: string): Promise<void> {
557
+ const res = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
558
+ const target = res.find((t: any) => t.id === targetId);
559
+ if (!target) throw new Error(`Target ${targetId} not found`);
560
+
561
+ currentTargetId = targetId;
562
+ connected = true;
563
+ lastCdpLivenessAt = Date.now();
564
+ try { await cdpSend('Page.enable', {}, 2000); } catch {}
565
+ try { await cdpSend('Page.bringToFront', {}, 2000); } catch {}
566
+ console.log(`[Empir3 Bridge] Switched to target: ${target.url}`);
567
+ }
568
+
569
+ function markCdpDisconnected(reason: string) {
570
+ if (connected || cdpWs) {
571
+ console.warn(`[Empir3 Bridge] CDP connection reset: ${reason}`);
572
+ }
573
+ connected = false;
574
+ lastCdpLivenessAt = 0;
575
+ if (cdpWs) {
576
+ try { cdpWs.close(); } catch {}
577
+ }
578
+ cdpWs = null;
579
+ const err = new Error(`CDP connection reset: ${reason}`);
580
+ for (const [, cb] of cdpCallbacks) {
581
+ try { cb.reject(err); } catch {}
582
+ }
583
+ cdpCallbacks.clear();
584
+ }
585
+
586
+ async function closePrimaryCdpForDirectCommand(): Promise<void> {
587
+ const ws = cdpWs;
588
+ if (!ws) return;
589
+ cdpWs = null;
590
+ connected = false;
591
+ const state = ws.readyState;
592
+ if (state === WebSocket.CLOSED || state === WebSocket.CLOSING) return;
593
+ await new Promise<void>((resolve) => {
594
+ let settled = false;
595
+ const finish = () => {
596
+ if (settled) return;
597
+ settled = true;
598
+ resolve();
599
+ };
600
+ ws.once('close', finish);
601
+ ws.once('error', finish);
602
+ try { ws.close(); } catch { finish(); }
603
+ setTimeout(finish, 250);
604
+ });
605
+ }
606
+
607
+ function withCdpCommandLock<T>(fn: () => Promise<T>): Promise<T> {
608
+ const run = cdpCommandQueue.then(fn, fn);
609
+ cdpCommandQueue = run.then(() => undefined, () => undefined);
610
+ return run;
611
+ }
612
+
613
+ function cdpSendRaw(method: string, params: any = {}, timeoutMs = NAV_TIMEOUT, resetOnTimeout = true): Promise<any> {
614
+ return new Promise((resolve, reject) => {
615
+ if (!cdpWs || cdpWs.readyState !== WebSocket.OPEN) {
616
+ return reject(new Error('CDP not connected'));
617
+ }
618
+ const ws = cdpWs;
619
+ const id = cdpId++;
620
+ cdpCallbacks.set(id, { resolve, reject, ws });
621
+ ws.send(JSON.stringify({ id, method, params }));
622
+ setTimeout(() => {
623
+ if (cdpCallbacks.has(id)) {
624
+ cdpCallbacks.delete(id);
625
+ if (resetOnTimeout && cdpWs === ws) markCdpDisconnected(`timeout waiting for ${method}`);
626
+ reject(new Error(`CDP timeout: ${method}`));
627
+ }
628
+ }, timeoutMs);
629
+ });
630
+ }
631
+
632
+ async function sendDirectCdpCommand(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
633
+ const target = await currentPageTarget();
634
+ await closePrimaryCdpForDirectCommand();
635
+ const result = await cdpSendViaDirectWs(target.webSocketDebuggerUrl, method, params, timeoutMs);
636
+ connected = true;
637
+ lastCdpLivenessAt = Date.now();
638
+ currentTargetId = target.id;
639
+ return result;
640
+ }
641
+
642
+ async function sendDetachedCdpCommand(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
643
+ const target = await currentPageTarget();
644
+ const result = await cdpSendViaDirectWs(target.webSocketDebuggerUrl, method, params, timeoutMs);
645
+ connected = true;
646
+ lastCdpLivenessAt = Date.now();
647
+ currentTargetId = target.id;
648
+ return result;
649
+ }
650
+
651
+ async function ensureBrowserWsReady(timeoutMs = 5000): Promise<void> {
652
+ const deadline = Date.now() + Math.max(500, timeoutMs);
653
+ while (Date.now() < deadline) {
654
+ if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
655
+ await connectBrowserWs().catch(() => {});
656
+ if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
657
+ await sleep(100);
658
+ }
659
+ throw new Error('Browser WS not connected');
660
+ }
661
+
662
+ function isBrowserDomainMethod(method: string): boolean {
663
+ return method.startsWith('Browser.') || method.startsWith('Target.');
664
+ }
665
+
666
+ async function sendCdpCommandViaBrowserSession(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
667
+ await ensureBrowserWsReady(timeoutMs);
668
+
669
+ if (isBrowserDomainMethod(method)) {
670
+ const result = await browserSend(method, params, timeoutMs);
671
+ connected = true;
672
+ lastCdpLivenessAt = Date.now();
673
+ return result;
674
+ }
675
+
676
+ const target = await currentPageTarget();
677
+ currentTargetId = target.id;
678
+ const attachResult = await browserSend('Target.attachToTarget', { targetId: target.id, flatten: true }, timeoutMs);
679
+ const sessionId = attachResult?.sessionId;
680
+ if (!sessionId) throw new Error('No sessionId from attachToTarget');
681
+
682
+ try {
683
+ const result = await browserSendWithSession(sessionId, method, params, timeoutMs);
684
+ connected = true;
685
+ lastCdpLivenessAt = Date.now();
686
+ return result;
687
+ } finally {
688
+ try { await browserSend('Target.detachFromTarget', { sessionId }, 1500); } catch {}
689
+ }
690
+ }
691
+
692
+ async function verifyCdpConnection(): Promise<boolean> {
693
+ if (!connected || !cdpWs || cdpWs.readyState !== WebSocket.OPEN) return false;
694
+ if (Date.now() - lastCdpLivenessAt < CDP_LIVENESS_MAX_AGE_MS) return true;
695
+ try {
696
+ await cdpSendRaw('Runtime.evaluate', {
697
+ expression: '1',
698
+ returnByValue: true,
699
+ }, CDP_LIVENESS_TIMEOUT_MS, true);
700
+ lastCdpLivenessAt = Date.now();
701
+ return true;
702
+ } catch (e: any) {
703
+ console.warn(`[Empir3 Bridge] CDP liveness check failed: ${e?.message || e}`);
704
+ return false;
705
+ }
706
+ }
707
+
708
+ async function currentPageTarget(): Promise<any> {
709
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
710
+ let target = targets.find((t: any) => t.type === 'page' && t.id === currentTargetId);
711
+ if (!target) {
712
+ target = await pickInitialTarget();
713
+ currentTargetId = target.id;
714
+ }
715
+ return target;
716
+ }
717
+
718
+ async function hasReachablePageTarget(): Promise<boolean> {
719
+ try {
720
+ const target = await currentPageTarget();
721
+ if (!target?.webSocketDebuggerUrl) return false;
722
+ return true;
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+
728
+ function cdpSendViaDirectWs(wsUrl: string, method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
729
+ return new Promise((resolve, reject) => {
730
+ const ws = new WebSocket(wsUrl);
731
+ const id = cdpId++;
732
+ let settled = false;
733
+ let timer: ReturnType<typeof setTimeout>;
734
+ const finish = (err?: Error, result?: any) => {
735
+ if (settled) return;
736
+ settled = true;
737
+ clearTimeout(timer);
738
+ try {
739
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
740
+ } catch {}
741
+ if (err) reject(err);
742
+ else resolve(result);
743
+ };
744
+ timer = setTimeout(() => {
745
+ try { (ws as any).terminate?.(); } catch {}
746
+ finish(new Error(`CDP direct timeout: ${method}`));
747
+ }, timeoutMs);
748
+
749
+ ws.on('open', () => {
750
+ ws.send(JSON.stringify({ id, method, params }));
751
+ });
752
+
753
+ ws.on('message', (data: Buffer) => {
754
+ if (settled) return;
755
+ try {
756
+ const msg = JSON.parse(data.toString());
757
+ if (msg.id !== id) return;
758
+ if (msg.error) finish(new Error(msg.error.message));
759
+ else {
760
+ lastCdpLivenessAt = Date.now();
761
+ finish(undefined, msg.result);
762
+ }
763
+ } catch (e: any) {
764
+ finish(e);
765
+ }
766
+ });
767
+
768
+ ws.on('error', (e) => {
769
+ finish(e instanceof Error ? e : new Error(String(e)));
770
+ });
771
+
772
+ ws.on('close', () => {
773
+ finish(new Error(`CDP direct connection closed: ${method}`));
774
+ });
775
+ });
776
+ }
777
+
778
+ async function cdpSend(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
779
+ return withCdpCommandLock(async () => {
780
+ try {
781
+ return await sendDetachedCdpCommand(method, params, timeoutMs);
782
+ } catch (e: any) {
783
+ const message = String(e?.message || e);
784
+ if (!/CDP not connected|CDP timeout|CDP connection reset|WebSocket|Browser WS|connection closed/i.test(message)) {
785
+ throw e;
786
+ }
787
+
788
+ await connectCDP().catch(() => {});
789
+ return sendDetachedCdpCommand(method, params, timeoutMs);
790
+ }
791
+ });
792
+ }
793
+
794
+ async function cdpSendNoReset(method: string, params: any = {}, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
795
+ return withCdpCommandLock(() => sendDetachedCdpCommand(method, params, timeoutMs));
796
+ }
797
+
798
+ async function captureScreenshot(params: any): Promise<any> {
799
+ return withCdpCommandLock(async () => {
800
+ try { await sendDetachedCdpCommand('Page.enable', {}, 2000); } catch {}
801
+ try {
802
+ return await sendDetachedCdpCommand('Page.captureScreenshot', params, Math.max(CDP_COMMAND_TIMEOUT_MS, 15000));
803
+ } catch (e: any) {
804
+ if (!/timeout|not connected|connection reset|connection closed/i.test(String(e?.message || e))) throw e;
805
+ await connectCDP().catch(() => {});
806
+ try { await sendDetachedCdpCommand('Page.enable', {}, 2000); } catch {}
807
+ return sendDetachedCdpCommand('Page.captureScreenshot', params, Math.max(CDP_COMMAND_TIMEOUT_MS, 15000));
808
+ }
809
+ });
810
+ }
811
+
812
+ async function autoDenyPermissions(): Promise<void> {
813
+ const perms = ['geolocation','notifications','midi','midi-sysex','clipboard-read',
814
+ 'clipboard-write','camera','microphone','background-sync','ambient-light-sensor',
815
+ 'accelerometer','gyroscope','magnetometer','accessibility-events','payment-handler',
816
+ 'idle-detection','storage-access','window-management'];
817
+ let denied = 0;
818
+ for (const name of perms) {
819
+ try {
820
+ await cdpSendRaw('Browser.setPermission', {
821
+ permission: { name },
822
+ setting: 'denied',
823
+ }, CDP_PERMISSION_TIMEOUT_MS, false);
824
+ denied++;
825
+ } catch {}
826
+ }
827
+ if (denied > 0) {
828
+ console.log(`[Empir3 Bridge] Auto-denied ${denied}/${perms.length} permission prompts`);
829
+ } else {
830
+ console.log('[Empir3 Bridge] Permission auto-deny skipped (Chrome did not acknowledge Browser.setPermission)');
831
+ }
832
+ }
833
+
834
+ // ─── --fresh: wipe site data ─────────────────────────────────
835
+
836
+ /**
837
+ * Clear cookies, localStorage, IndexedDB, service workers, and cache for
838
+ * every origin that has stored data in this profile. Called once per Chrome
839
+ * launch when EMPIR3_BRIDGE_FRESH=1 (set by `npm start -- --fresh`).
840
+ *
841
+ * Strategy:
842
+ * 1. Network.clearBrowserCookies — wipes cookies for all origins
843
+ * 2. Network.clearBrowserCache — wipes the HTTP cache
844
+ * 3. Storage.clearDataForOrigin('*') with all data types — covers
845
+ * localStorage, IndexedDB, service workers, cache storage, etc.
846
+ *
847
+ * Extensions, settings, history, autofill, and the profile dir itself are
848
+ * preserved — only site-data is wiped. This matches the user-visible
849
+ * meaning of "fresh user state".
850
+ */
851
+ async function wipeAllStorage(): Promise<void> {
852
+ console.log('[Empir3 Bridge] --fresh: clearing cookies + localStorage + IndexedDB...');
853
+ const dataTypes = [
854
+ 'cookies',
855
+ 'local_storage',
856
+ 'indexeddb',
857
+ 'service_workers',
858
+ 'cache_storage',
859
+ 'websql',
860
+ 'file_systems',
861
+ 'shader_cache',
862
+ ].join(',');
863
+
864
+ let cleared = 0;
865
+ try {
866
+ await cdpSend('Network.clearBrowserCookies', {});
867
+ cleared++;
868
+ } catch (e: any) {
869
+ console.log(`[Empir3 Bridge] clearBrowserCookies failed: ${e.message}`);
870
+ }
871
+ try {
872
+ await cdpSend('Network.clearBrowserCache', {});
873
+ cleared++;
874
+ } catch (e: any) {
875
+ console.log(`[Empir3 Bridge] clearBrowserCache failed: ${e.message}`);
876
+ }
877
+ // CDP requires a real origin URL — '*' isn't a wildcard. Pass an empty
878
+ // origin so Chrome treats it as a profile-wide clear when supported,
879
+ // and ALSO walk the storage list to catch every origin explicitly.
880
+ try {
881
+ await cdpSend('Storage.clearDataForOrigin', { origin: '*', storageTypes: dataTypes });
882
+ cleared++;
883
+ } catch {
884
+ // 'all' wildcard isn't supported on every Chrome build — fall through to
885
+ // the per-origin loop below.
886
+ }
887
+ try {
888
+ const usage = await cdpSend('Storage.getUsageAndQuota', { origin: 'about:blank' });
889
+ // Some Chrome builds expose origins via Storage.trackIndexedDBForOrigin
890
+ // notifications, but the cheap path is enumerating navigation history.
891
+ // Skip if usage call failed — clearDataForOrigin('*') already did the job.
892
+ void usage;
893
+ } catch {}
894
+ console.log(`[Empir3 Bridge] --fresh: ${cleared}/3 wipe steps succeeded`);
895
+ }
896
+
897
+ // ─── CDP Helpers ─────────────────────────────────────────────
898
+
899
+ async function cdpEvaluate(expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
900
+ const result = await cdpSend('Runtime.evaluate', {
901
+ expression,
902
+ returnByValue: true,
903
+ awaitPromise: true,
904
+ }, timeoutMs);
905
+ if (result.exceptionDetails) {
906
+ // CDP's exceptionDetails.text is usually just "Uncaught" — useless on its own.
907
+ // The actual error message + stack lives in exception.description.
908
+ // Trim trailing newlines and cap at 500 chars so error fits in one line.
909
+ const ex = result.exceptionDetails;
910
+ const desc = (ex.exception && ex.exception.description) || ex.text || 'JS evaluation error';
911
+ const trimmed = String(desc).split('\n')[0].slice(0, 500);
912
+ throw new Error(trimmed);
913
+ }
914
+ return result.result?.value;
915
+ }
916
+
917
+ async function cdpNavigate(url: string): Promise<void> {
918
+ try {
919
+ await cdpSend('Page.navigate', { url }, Math.min(CDP_COMMAND_TIMEOUT_MS, 5000));
920
+ } catch (e: any) {
921
+ if (!(await waitForTargetUrl(url, 5000))) throw e;
922
+ }
923
+ // Chrome target metadata normally updates before page-level Runtime.evaluate
924
+ // is ready. That is enough for browser_control.open; text/snapshot can read
925
+ // the page afterward without making open wait on a slow eval loop.
926
+ await waitForTargetUrl(url, 8000);
927
+ try { await cdpSend('Page.enable'); } catch {}
928
+ try { await cdpEvaluate('document.readyState', 1200); } catch {}
929
+ }
930
+
931
+ async function waitForTargetUrl(expectedUrl: string, timeoutMs: number): Promise<boolean> {
932
+ const deadline = Date.now() + timeoutMs;
933
+ const normalizeUrl = (u: string) => u.replace(/[#/]+$/, '');
934
+ while (Date.now() < deadline) {
935
+ try {
936
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
937
+ const target = targets.find((t: any) => t.type === 'page' && t.id === currentTargetId)
938
+ || targets.find((t: any) => t.type === 'page' && normalizeUrl(String(t.url || '')) === normalizeUrl(expectedUrl));
939
+ if (target && normalizeUrl(String(target.url || '')) === normalizeUrl(expectedUrl)) {
940
+ currentTargetId = target.id;
941
+ return true;
942
+ }
943
+ } catch {}
944
+ await sleep(250);
945
+ }
946
+ return false;
947
+ }
948
+
949
+ // ─── Per-Target Evaluation (for injecting into non-active tabs) ──
950
+
951
+ /**
952
+ * Evaluate JS on a specific target by opening a temporary CDP WS connection.
953
+ * Does NOT switch the active target — the main cdpWs stays on currentTargetId.
954
+ */
955
+ async function evaluateOnTarget(targetId: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
956
+ // If it's the current target, just use the existing connection
957
+ if (targetId === currentTargetId && cdpWs) {
958
+ return cdpEvaluate(expression, timeoutMs);
959
+ }
960
+
961
+ // Try direct WS via HTTP /json endpoint (works for bridge-known targets)
962
+ try {
963
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
964
+ const target = targets.find((t: any) => t.id === targetId && t.type === 'page');
965
+ if (target?.webSocketDebuggerUrl) {
966
+ return await evaluateViaDirectWs(target.webSocketDebuggerUrl, expression, timeoutMs);
967
+ }
968
+ } catch {}
969
+
970
+ // Fallback: use browser WS with Target.attachToTarget (for user-opened tabs not in /json)
971
+ if (browserWs && browserWs.readyState === WebSocket.OPEN) {
972
+ return await evaluateViaBrowserSession(targetId, expression, timeoutMs);
973
+ }
974
+
975
+ throw new Error(`Target ${targetId} not reachable via /json or browser WS`);
976
+ }
977
+
978
+ /** Evaluate via a temporary direct WS connection to a target */
979
+ function evaluateViaDirectWs(wsUrl: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
980
+ return withCdpCommandLock(() => new Promise((resolve, reject) => {
981
+ const ws = new WebSocket(wsUrl);
982
+ let settled = false;
983
+ let timer: ReturnType<typeof setTimeout>;
984
+ const finish = (err?: Error, result?: any) => {
985
+ if (settled) return;
986
+ settled = true;
987
+ clearTimeout(timer);
988
+ try {
989
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close();
990
+ } catch {}
991
+ if (err) reject(err);
992
+ else resolve(result);
993
+ };
994
+ timer = setTimeout(() => {
995
+ try { (ws as any).terminate?.(); } catch {}
996
+ finish(new Error('evaluate-on-target timeout'));
997
+ }, timeoutMs);
998
+
999
+ ws.on('open', () => {
1000
+ const id = cdpId++;
1001
+ ws.send(JSON.stringify({
1002
+ id,
1003
+ method: 'Runtime.evaluate',
1004
+ params: { expression, returnByValue: true, awaitPromise: true },
1005
+ }));
1006
+ ws.on('message', (data: Buffer) => {
1007
+ try {
1008
+ const msg = JSON.parse(data.toString());
1009
+ if (msg.id === id) {
1010
+ if (msg.error) finish(new Error(msg.error.message));
1011
+ else finish(undefined, msg.result?.result?.value);
1012
+ }
1013
+ } catch (e: any) {
1014
+ finish(e);
1015
+ }
1016
+ });
1017
+ });
1018
+
1019
+ ws.on('error', (e) => finish(e instanceof Error ? e : new Error(String(e))));
1020
+ ws.on('close', () => finish(new Error('evaluate-on-target connection closed')));
1021
+ }));
1022
+ }
1023
+
1024
+ /** Evaluate via browser WS using Target.attachToTarget flat session */
1025
+ async function evaluateViaBrowserSession(targetId: string, expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any> {
1026
+ if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
1027
+ throw new Error('Browser WS not connected');
1028
+ }
1029
+
1030
+ // Attach to target to get a session
1031
+ const attachResult = await browserSend('Target.attachToTarget', { targetId, flatten: true }, timeoutMs);
1032
+ const sessionId = attachResult?.sessionId;
1033
+ if (!sessionId) throw new Error('No sessionId from attachToTarget');
1034
+
1035
+ try {
1036
+ // Evaluate via the flat session
1037
+ const evalResult = await browserSendWithSession(sessionId, 'Runtime.evaluate', {
1038
+ expression, returnByValue: true, awaitPromise: true,
1039
+ }, timeoutMs);
1040
+
1041
+ return evalResult?.result?.value;
1042
+ } finally {
1043
+ // Detach to clean up
1044
+ try {
1045
+ await browserSend('Target.detachFromTarget', { sessionId }, 1500);
1046
+ } catch {}
1047
+ }
1048
+ }
1049
+
1050
+ /** Send a command on the browser WS (no session) */
1051
+ function browserSend(method: string, params: any = {}, timeoutMs = 10000): Promise<any> {
1052
+ return new Promise((resolve, reject) => {
1053
+ if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
1054
+ return reject(new Error('Browser WS not connected'));
1055
+ }
1056
+ const id = browserCdpId++;
1057
+ browserCallbacks.set(id, { resolve, reject });
1058
+ browserWs.send(JSON.stringify({ id, method, params }));
1059
+ setTimeout(() => {
1060
+ if (browserCallbacks.has(id)) {
1061
+ browserCallbacks.delete(id);
1062
+ reject(new Error(`Browser WS timeout: ${method}`));
1063
+ }
1064
+ }, timeoutMs);
1065
+ });
1066
+ }
1067
+
1068
+ /** Send a command on the browser WS with a flat-session sessionId */
1069
+ function browserSendWithSession(sessionId: string, method: string, params: any = {}, timeoutMs = 10000): Promise<any> {
1070
+ return new Promise((resolve, reject) => {
1071
+ if (!browserWs || browserWs.readyState !== WebSocket.OPEN) {
1072
+ return reject(new Error('Browser WS not connected'));
1073
+ }
1074
+ const id = browserCdpId++;
1075
+ browserCallbacks.set(id, { resolve, reject });
1076
+ browserWs.send(JSON.stringify({ id, sessionId, method, params }));
1077
+ setTimeout(() => {
1078
+ if (browserCallbacks.has(id)) {
1079
+ browserCallbacks.delete(id);
1080
+ reject(new Error(`Browser session timeout: ${method}`));
1081
+ }
1082
+ }, timeoutMs);
1083
+ });
1084
+ }
1085
+
1086
+ /**
1087
+ * Evaluate JS on ALL known page targets. Returns array of {targetId, url, ok, result/error}.
1088
+ */
1089
+ async function evaluateOnAllTargets(expression: string, timeoutMs = CDP_COMMAND_TIMEOUT_MS): Promise<any[]> {
1090
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
1091
+ const pages = targets.filter((t: any) => t.type === 'page');
1092
+ const results: any[] = [];
1093
+
1094
+ for (const page of pages) {
1095
+ try {
1096
+ const result = await evaluateOnTarget(page.id, expression, timeoutMs);
1097
+ results.push({ targetId: page.id, url: page.url, ok: true, result });
1098
+ } catch (e: any) {
1099
+ results.push({ targetId: page.id, url: page.url, ok: false, error: e.message });
1100
+ }
1101
+ }
1102
+ return results;
1103
+ }
1104
+
1105
+ // ─── Target Discovery — poll for new tabs and auto-inject ────
1106
+
1107
+ // Auto-inject must not hold the single global CDP command lock for the full 10s
1108
+ // default — a stalled eval against a heavy page (e.g. the bridge's own welcome
1109
+ // console) would block every other CDP op and starve the shared event loop, so
1110
+ // trivial HTTP handlers (/api/status, /api/relay-status) queue for seconds and the
1111
+ // tray's liveness poll times out. Cap it short so a stuck inject releases the lock fast.
1112
+ const AUTO_INJECT_TIMEOUT_MS = 2000;
1113
+ let pollInFlight = false;
1114
+ async function pollTargets() {
1115
+ // Never overlap: a single auto-inject can await up to AUTO_INJECT_TIMEOUT_MS, which
1116
+ // is longer than the 500ms poll interval. Without this guard, successive ticks pile
1117
+ // concurrent fresh-WS Runtime.evaluate calls onto the loop — the saturation storm.
1118
+ if (pollInFlight) return;
1119
+ pollInFlight = true;
1120
+ try {
1121
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
1122
+ const pages = targets.filter((t: any) => t.type === 'page');
1123
+ if (pages.length === 0) {
1124
+ // Don't latch closed on a single empty poll — route through the debounced
1125
+ // confirmation, which re-polls /json a few times before concluding the user
1126
+ // closed the browser. Prevents the transient-empty post-refresh flap.
1127
+ markClosedIfNoPageTargets('no page targets in poll').catch(() => {});
1128
+ return;
1129
+ }
1130
+
1131
+ for (const page of pages) {
1132
+ const prevUrl = knownTargets.get(page.id);
1133
+ const isNew = prevUrl === undefined;
1134
+ const urlChanged = prevUrl !== undefined && prevUrl !== page.url;
1135
+
1136
+ // Skip chrome:// and about: pages — can't inject JS into them.
1137
+ // Don't mark them as known so we re-check when they navigate to a real URL.
1138
+ if (page.url.startsWith('chrome://') || page.url.startsWith('about:') || page.url.startsWith('devtools://')) {
1139
+ continue;
1140
+ }
1141
+
1142
+ if (isNew || urlChanged) {
1143
+ knownTargets.set(page.id, page.url);
1144
+ if (isNew) {
1145
+ console.log(`[Empir3 Bridge] New tab detected: ${page.url} (${page.id})`);
1146
+ } else {
1147
+ console.log(`[Empir3 Bridge] Tab navigated: ${prevUrl} → ${page.url} (${page.id})`);
1148
+ }
1149
+
1150
+ // Auto-inject registered script (short timeout — see AUTO_INJECT_TIMEOUT_MS).
1151
+ if (autoInjectScript) {
1152
+ try {
1153
+ await evaluateOnTarget(page.id, autoInjectScript, AUTO_INJECT_TIMEOUT_MS);
1154
+ console.log(`[Empir3 Bridge] Auto-injected into: ${page.url}`);
1155
+ } catch (e: any) {
1156
+ console.log(`[Empir3 Bridge] Auto-inject failed for ${page.url}: ${e.message?.slice(0, 60)}`);
1157
+ }
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ // Prune destroyed targets
1163
+ const currentIds = new Set(pages.map((p: any) => p.id));
1164
+ for (const id of knownTargets.keys()) {
1165
+ if (!currentIds.has(id)) {
1166
+ knownTargets.delete(id);
1167
+ }
1168
+ }
1169
+ } catch {
1170
+ // Bridge may be busy or Chrome not ready
1171
+ } finally {
1172
+ pollInFlight = false;
1173
+ }
1174
+ }
1175
+
1176
+ function startTargetPolling() {
1177
+ if (targetPollTimer) return;
1178
+ // Seed known targets
1179
+ pollTargets();
1180
+ // Poll every 500ms for new tabs — fast enough to inject overlay before user interacts
1181
+ targetPollTimer = setInterval(pollTargets, 500);
1182
+ console.log('[Empir3 Bridge] Target polling started (500ms interval)');
1183
+ }
1184
+
1185
+ function stopTargetPolling() {
1186
+ if (targetPollTimer) {
1187
+ clearInterval(targetPollTimer);
1188
+ targetPollTimer = null;
1189
+ }
1190
+ }
1191
+
1192
+ // ─── Browser-Level Target Discovery ─────────────────────────
1193
+ // Connects to Chrome's browser WS to receive Target.targetCreated events
1194
+ // for ALL tabs — including ones the user opens manually.
1195
+
1196
+ async function connectBrowserWs(): Promise<void> {
1197
+ if (browserWs && browserWs.readyState === WebSocket.OPEN) return;
1198
+ if (browserWsConnecting) return;
1199
+ browserWsConnecting = true;
1200
+ try {
1201
+ const versionInfo = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json/version`);
1202
+ const wsUrl = versionInfo.webSocketDebuggerUrl;
1203
+ if (!wsUrl) {
1204
+ console.log('[Empir3 Bridge] No browser WS URL available');
1205
+ browserWsConnecting = false;
1206
+ return;
1207
+ }
1208
+
1209
+ return new Promise((resolve) => {
1210
+ const ws = new WebSocket(wsUrl);
1211
+ let settled = false;
1212
+ const finish = () => {
1213
+ if (settled) return;
1214
+ settled = true;
1215
+ browserWsConnecting = false;
1216
+ resolve();
1217
+ };
1218
+
1219
+ ws.on('open', () => {
1220
+ browserWs = ws;
1221
+ if (browserReconnectTimer) {
1222
+ clearTimeout(browserReconnectTimer);
1223
+ browserReconnectTimer = null;
1224
+ }
1225
+ console.log('[Empir3 Bridge] Browser-level WS connected');
1226
+ // Subscribe to all target events
1227
+ const id = browserCdpId++;
1228
+ ws.send(JSON.stringify({ id, method: 'Target.setDiscoverTargets', params: { discover: true } }));
1229
+ finish();
1230
+ });
1231
+
1232
+ ws.on('message', (data: Buffer) => {
1233
+ try {
1234
+ const msg = JSON.parse(data.toString());
1235
+
1236
+ // Handle responses to our commands
1237
+ if (msg.id && browserCallbacks.has(msg.id)) {
1238
+ const cb = browserCallbacks.get(msg.id)!;
1239
+ browserCallbacks.delete(msg.id);
1240
+ if (msg.error) cb.reject(new Error(msg.error.message));
1241
+ else cb.resolve(msg.result);
1242
+ }
1243
+
1244
+ // Handle target events
1245
+ if (msg.method === 'Target.targetCreated') {
1246
+ const info = msg.params?.targetInfo;
1247
+ if (info?.type === 'page') {
1248
+ handleNewTarget(info.targetId, info.url);
1249
+ }
1250
+ }
1251
+
1252
+ if (msg.method === 'Target.targetInfoChanged') {
1253
+ const info = msg.params?.targetInfo;
1254
+ if (info?.type === 'page') {
1255
+ handleTargetUrlChange(info.targetId, info.url);
1256
+ }
1257
+ }
1258
+
1259
+ if (msg.method === 'Target.targetDestroyed') {
1260
+ knownTargets.delete(msg.params?.targetId);
1261
+ markClosedIfNoPageTargets('last page target destroyed').catch(() => {});
1262
+ }
1263
+ } catch {}
1264
+ });
1265
+
1266
+ ws.on('close', () => {
1267
+ if (browserWs === ws) {
1268
+ browserWs = null;
1269
+ if (!chromeProcess || chromeClosedByUser || shuttingDown) {
1270
+ console.log('[Empir3 Bridge] Browser WS disconnected');
1271
+ } else if (!browserReconnectTimer) {
1272
+ console.log('[Empir3 Bridge] Browser WS disconnected - reconnecting in 3s');
1273
+ browserReconnectTimer = setTimeout(() => {
1274
+ browserReconnectTimer = null;
1275
+ connectBrowserWs().catch(() => {});
1276
+ }, 3000);
1277
+ }
1278
+ }
1279
+ finish();
1280
+ });
1281
+
1282
+ ws.on('error', () => {
1283
+ if (browserWs === ws) browserWs = null;
1284
+ finish(); // don't block startup
1285
+ });
1286
+
1287
+ setTimeout(finish, 5000); // timeout fallback
1288
+ });
1289
+ } catch (e: any) {
1290
+ browserWsConnecting = false;
1291
+ console.log(`[Empir3 Bridge] Browser WS connect failed: ${e.message?.slice(0, 60)}`);
1292
+ }
1293
+ }
1294
+
1295
+ function handleNewTarget(targetId: string, url: string) {
1296
+ // Skip chrome:// and internal pages
1297
+ if (url.startsWith('chrome://') || url.startsWith('about:') || url.startsWith('devtools://')) return;
1298
+
1299
+ if (!knownTargets.has(targetId)) {
1300
+ knownTargets.set(targetId, url);
1301
+ console.log(`[Empir3 Bridge] [Browser WS] New tab: ${url} (${targetId.slice(0, 8)})`);
1302
+ autoInjectIntoTarget(targetId, url);
1303
+ }
1304
+ }
1305
+
1306
+ function handleTargetUrlChange(targetId: string, url: string) {
1307
+ if (url.startsWith('chrome://') || url.startsWith('about:') || url.startsWith('devtools://')) return;
1308
+
1309
+ const prevUrl = knownTargets.get(targetId);
1310
+ if (prevUrl !== url) {
1311
+ knownTargets.set(targetId, url);
1312
+ console.log(`[Empir3 Bridge] [Browser WS] Tab navigated: ${(prevUrl || '(new)').slice(0, 40)} → ${url.slice(0, 40)}`);
1313
+ autoInjectIntoTarget(targetId, url);
1314
+ }
1315
+ }
1316
+
1317
+ async function autoInjectIntoTarget(targetId: string, url: string) {
1318
+ if (!autoInjectScript) return;
1319
+ // Small delay — page may still be loading
1320
+ await sleep(300);
1321
+ try {
1322
+ await evaluateOnTarget(targetId, autoInjectScript);
1323
+ console.log(`[Empir3 Bridge] Auto-injected into: ${url.slice(0, 50)}`);
1324
+ } catch (e: any) {
1325
+ // Retry once after a longer delay (page might not be ready)
1326
+ await sleep(1000);
1327
+ try {
1328
+ await evaluateOnTarget(targetId, autoInjectScript);
1329
+ console.log(`[Empir3 Bridge] Auto-injected into (retry): ${url.slice(0, 50)}`);
1330
+ } catch {
1331
+ console.log(`[Empir3 Bridge] Auto-inject failed for ${url.slice(0, 40)}: ${e.message?.slice(0, 40)}`);
1332
+ }
1333
+ }
1334
+ }
1335
+
1336
+ async function cdpScreenshot(maxWidth?: number): Promise<Buffer> {
1337
+ const params: any = { format: 'jpeg', quality: 80 };
1338
+ // If maxWidth specified, cap output image dimensions (accounting for devicePixelRatio)
1339
+ if (maxWidth && maxWidth > 0) {
1340
+ try {
1341
+ const evalResult = await cdpSend('Runtime.evaluate', {
1342
+ expression: 'JSON.stringify({w:window.innerWidth,h:window.innerHeight,dpr:window.devicePixelRatio||1})',
1343
+ returnByValue: true,
1344
+ });
1345
+ const vp = JSON.parse(evalResult.result.value);
1346
+ if (vp.w > 0 && vp.h > 0 && vp.dpr > 0) {
1347
+ const physicalWidth = vp.w * vp.dpr;
1348
+ if (physicalWidth > maxWidth) {
1349
+ const scale = Math.max(0.1, Math.min(2.0, maxWidth / physicalWidth));
1350
+ params.clip = { x: 0, y: 0, width: vp.w, height: vp.h, scale };
1351
+ }
1352
+ }
1353
+ } catch {}
1354
+ }
1355
+ const result = await captureScreenshot(params);
1356
+ return Buffer.from(result.data, 'base64');
1357
+ }
1358
+
1359
+ async function getAccessibilityTree(filter: string = 'interactive'): Promise<any[]> {
1360
+ // Use DOM + accessibility API to build element refs
1361
+ const nodes: any[] = [];
1362
+ refMap.clear();
1363
+ refCounter = 0;
1364
+
1365
+ const jsCode = `(function() {
1366
+ const results = [];
1367
+ const interactiveRoles = new Set([
1368
+ 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
1369
+ 'menuitem', 'tab', 'switch', 'slider', 'spinbutton', 'searchbox',
1370
+ 'option', 'menuitemcheckbox', 'menuitemradio', 'treeitem'
1371
+ ]);
1372
+ const interactiveTags = new Set([
1373
+ 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'DETAILS', 'SUMMARY'
1374
+ ]);
1375
+
1376
+ const walker = document.createTreeWalker(
1377
+ document.body,
1378
+ NodeFilter.SHOW_ELEMENT,
1379
+ null
1380
+ );
1381
+
1382
+ let node = walker.currentNode;
1383
+ let refIdx = 0;
1384
+ const visited = new Set();
1385
+
1386
+ function processNode(el) {
1387
+ if (visited.has(el)) return;
1388
+ visited.add(el);
1389
+
1390
+ // Skip the bridge's own injected overlay UI (id="empir3-*" roots and
1391
+ // everything inside them) — its chat input, toolbar, and mode buttons
1392
+ // are "interactive" and would otherwise pollute the snapshot with refs
1393
+ // that don't belong to the page under test.
1394
+ if (el.closest && el.closest('[id^="empir3-"]')) return;
1395
+
1396
+ const role = el.getAttribute('role') || '';
1397
+ const tag = el.tagName;
1398
+ // Interactive-state ARIA attributes mark click targets that are otherwise
1399
+ // plain <div>/<span> with addEventListener handlers (which can't be read
1400
+ // from the DOM). Widening on these + contenteditable + an explicit onclick
1401
+ // attribute catches more styled-div click targets without flooding.
1402
+ const interactiveAttrs = ['aria-haspopup', 'aria-expanded', 'aria-pressed', 'aria-checked', 'aria-selected', 'onclick'];
1403
+ const hasInteractiveAttr = interactiveAttrs.some(a => el.hasAttribute(a));
1404
+ const editable = el.isContentEditable === true;
1405
+ const isInteractive = interactiveRoles.has(role) ||
1406
+ interactiveTags.has(tag) ||
1407
+ el.onclick ||
1408
+ el.hasAttribute('tabindex') ||
1409
+ (el.hasAttribute('data-testid')) ||
1410
+ hasInteractiveAttr ||
1411
+ editable ||
1412
+ getComputedStyle(el).cursor === 'pointer';
1413
+
1414
+ if (${filter === 'all' ? 'true' : 'isInteractive'}) {
1415
+ const rect = el.getBoundingClientRect();
1416
+ if (rect.width === 0 && rect.height === 0) return;
1417
+ if (rect.right < 0 || rect.bottom < 0 || rect.left > window.innerWidth || rect.top > window.innerHeight) return;
1418
+ if (getComputedStyle(el).display === 'none') return;
1419
+ if (getComputedStyle(el).visibility === 'hidden') return;
1420
+
1421
+ const name = el.getAttribute('aria-label') ||
1422
+ el.getAttribute('title') ||
1423
+ el.getAttribute('placeholder') ||
1424
+ el.innerText?.slice(0, 80)?.trim() || '';
1425
+
1426
+ const ref = 'e' + refIdx++;
1427
+ // Store a unique path for later retrieval
1428
+ el.setAttribute('data-empir3-ref', ref);
1429
+
1430
+ results.push({
1431
+ ref: ref,
1432
+ role: role || tag.toLowerCase(),
1433
+ name: name,
1434
+ bounds: {
1435
+ x: Math.round(rect.x),
1436
+ y: Math.round(rect.y),
1437
+ width: Math.round(rect.width),
1438
+ height: Math.round(rect.height),
1439
+ }
1440
+ });
1441
+ }
1442
+ }
1443
+
1444
+ processNode(node);
1445
+ while (node = walker.nextNode()) {
1446
+ processNode(node);
1447
+ }
1448
+
1449
+ return JSON.stringify(results);
1450
+ })()`;
1451
+
1452
+ const resultStr = await cdpEvaluate(jsCode);
1453
+ return JSON.parse(resultStr || '[]');
1454
+ }
1455
+
1456
+ async function clickByRef(ref: string): Promise<void> {
1457
+ // Scroll the element into view, resolve its CSS-pixel center, then issue a
1458
+ // REAL mouse press/release there via clickByXY. The old path used el.click(),
1459
+ // a synthetic event that silently no-ops on React-Native-Web Pressables
1460
+ // (they listen for pointer down/up, not a synthetic click) — the root cause
1461
+ // of "click_ref did nothing" on RN-Web. clickByXY already fires a trusted
1462
+ // CDP mouse sequence at CSS coords, so this reuses the verified click path
1463
+ // without touching its (CSS-pixel) coordinate contract.
1464
+ const centerStr = await cdpEvaluate(`(function() {
1465
+ const el = document.querySelector('[data-empir3-ref="${ref}"]');
1466
+ if (!el) throw new Error('Element not found: ${ref}');
1467
+ (el.scrollIntoViewIfNeeded ? el.scrollIntoViewIfNeeded() : el.scrollIntoView({ block: 'center', inline: 'center' }));
1468
+ const r = el.getBoundingClientRect();
1469
+ return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
1470
+ })()`);
1471
+ const c = JSON.parse(centerStr || '{}');
1472
+ if (typeof c.x !== 'number' || typeof c.y !== 'number') throw new Error('Element not found: ' + ref);
1473
+ await clickByXY(c.x, c.y);
1474
+ }
1475
+
1476
+ async function clickByXY(x: number, y: number): Promise<void> {
1477
+ await cdpSend('Input.dispatchMouseEvent', {
1478
+ type: 'mouseMoved',
1479
+ x,
1480
+ y,
1481
+ button: 'none',
1482
+ });
1483
+ await sleep(40);
1484
+ await cdpSend('Input.dispatchMouseEvent', {
1485
+ type: 'mousePressed',
1486
+ x,
1487
+ y,
1488
+ button: 'left',
1489
+ buttons: 1,
1490
+ clickCount: 1,
1491
+ });
1492
+ await sleep(40);
1493
+ await cdpSend('Input.dispatchMouseEvent', {
1494
+ type: 'mouseReleased',
1495
+ x,
1496
+ y,
1497
+ button: 'left',
1498
+ buttons: 0,
1499
+ clickCount: 1,
1500
+ });
1501
+ }
1502
+
1503
+ async function typeText(text: string): Promise<void> {
1504
+ for (const char of text) {
1505
+ await cdpSend('Input.dispatchKeyEvent', {
1506
+ type: 'keyDown',
1507
+ text: char,
1508
+ key: char,
1509
+ unmodifiedText: char,
1510
+ });
1511
+ await cdpSend('Input.dispatchKeyEvent', {
1512
+ type: 'keyUp',
1513
+ key: char,
1514
+ });
1515
+ await sleep(20);
1516
+ }
1517
+ }
1518
+
1519
+ async function pressKey(key: string): Promise<void> {
1520
+ // Map common key names to CDP key codes
1521
+ // `text` matters: CDP only fires a key's DEFAULT ACTION (form submit on
1522
+ // Enter, newline in a textarea, the space char) when the keyDown carries the
1523
+ // produced character — same reason typeText() sets text per char. Without it
1524
+ // the event reaches JS listeners but the browser does nothing (verified
1525
+ // 2026-06-02: Enter on a focused search field never submitted). Keys with no
1526
+ // character (Tab/Escape/arrows) intentionally have no text.
1527
+ const keyMap: Record<string, { key: string; code: string; keyCode: number; text?: string }> = {
1528
+ 'Enter': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
1529
+ 'Tab': { key: 'Tab', code: 'Tab', keyCode: 9 },
1530
+ 'Escape': { key: 'Escape', code: 'Escape', keyCode: 27 },
1531
+ 'Backspace': { key: 'Backspace', code: 'Backspace', keyCode: 8 },
1532
+ 'Delete': { key: 'Delete', code: 'Delete', keyCode: 46 },
1533
+ 'ArrowUp': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
1534
+ 'ArrowDown': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
1535
+ 'ArrowLeft': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
1536
+ 'ArrowRight': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
1537
+ 'Space': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
1538
+ };
1539
+
1540
+ // Handle modifier combos like "Control+a"
1541
+ if (key.includes('+')) {
1542
+ const parts = key.split('+');
1543
+ const modifier = parts[0].toLowerCase();
1544
+ const mainKey = parts[1];
1545
+
1546
+ const modifiers = modifier === 'control' || modifier === 'ctrl' ? 2 :
1547
+ modifier === 'shift' ? 8 :
1548
+ modifier === 'alt' ? 1 : 0;
1549
+
1550
+ await cdpSend('Input.dispatchKeyEvent', {
1551
+ type: 'keyDown',
1552
+ key: mainKey.length === 1 ? mainKey : mainKey,
1553
+ code: `Key${mainKey.toUpperCase()}`,
1554
+ modifiers,
1555
+ });
1556
+ await cdpSend('Input.dispatchKeyEvent', {
1557
+ type: 'keyUp',
1558
+ key: mainKey,
1559
+ modifiers,
1560
+ });
1561
+ return;
1562
+ }
1563
+
1564
+ const mapped = keyMap[key] || { key, code: key, keyCode: 0 };
1565
+ // A bare single character passed to press ("a") should also type, so give it
1566
+ // text too; mapped.text wins for the named keys above.
1567
+ const text = mapped.text !== undefined ? mapped.text : (key.length === 1 ? key : undefined);
1568
+ const down: any = {
1569
+ type: 'keyDown',
1570
+ key: mapped.key,
1571
+ code: mapped.code,
1572
+ windowsVirtualKeyCode: mapped.keyCode,
1573
+ };
1574
+ if (text !== undefined) { down.text = text; down.unmodifiedText = text; }
1575
+ await cdpSend('Input.dispatchKeyEvent', down);
1576
+ await cdpSend('Input.dispatchKeyEvent', {
1577
+ type: 'keyUp',
1578
+ key: mapped.key,
1579
+ code: mapped.code,
1580
+ windowsVirtualKeyCode: mapped.keyCode,
1581
+ });
1582
+ }
1583
+
1584
+ // ─── Welcome Page ────────────────────────────────────────────
1585
+
1586
+ function getWelcomeHtml() {
1587
+ const api = '';
1588
+ return `<!doctype html>
1589
+ <html>
1590
+ <head>
1591
+ <meta charset="utf-8">
1592
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1593
+ <title>empir3 Bridge Setup</title>
1594
+ <style>
1595
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
1596
+ * { box-sizing: border-box; }
1597
+ :root { --bg:#f5efe2; --bg2:#ede5d3; --surface:rgba(255,252,244,.88); --surface-strong:rgba(255,252,244,.96); --surface-muted:rgba(245,238,224,.72); --line:rgba(28,22,10,.12); --line-strong:rgba(28,22,10,.18); --text:#1c160a; --muted:#5a4f3d; --soft:#8a8070; --accent:#6b4ef0; --accent-2:#8c6bff; --good:#10b981; --shadow:rgba(28,22,10,.08); }
1598
+ body { margin:0; min-height:100vh; background:linear-gradient(rgba(28,22,10,.035) 1px, transparent 1px) 0 0/42px 42px, linear-gradient(90deg, rgba(28,22,10,.035) 1px, transparent 1px) 0 0/42px 42px, var(--bg); color:var(--text); font-family:'Outfit',system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
1599
+ main { width:min(1180px, calc(100vw - 40px)); margin:0 auto; padding:64px 0; display:grid; grid-template-columns:minmax(280px,380px) 1fr; gap:48px; align-items:start; }
1600
+ .brand { padding-top:8px; }
1601
+ .wordmark { display:inline-flex; align-items:baseline; gap:0; font-weight:900; letter-spacing:-.055em; line-height:.9; color:var(--text); }
1602
+ .wordmark .three, .brand-inline .three { color:var(--accent); }
1603
+ .wordmark-xl { font-size:clamp(58px, 7vw, 88px); margin-bottom:8px; }
1604
+ .brand-inline { display:inline-flex; align-items:baseline; gap:0; font-weight:800; letter-spacing:-.035em; color:var(--text); }
1605
+ .product-kicker { margin:0 0 42px; color:var(--soft); text-transform:uppercase; font-size:12px; font-weight:700; letter-spacing:.18em; }
1606
+ h1 { margin:0 0 16px; font-size:34px; line-height:1.05; letter-spacing:0; max-width:10ch; }
1607
+ h2 { margin:0; font-size:23px; line-height:1.2; letter-spacing:-.01em; }
1608
+ p { margin:0; color:var(--muted); line-height:1.55; }
1609
+ .lede { max-width:35ch; font-size:17px; line-height:1.62; }
1610
+ .shell { display:grid; gap:18px; }
1611
+ .mode-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
1612
+ .mode { text-align:left; min-height:132px; padding:18px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); cursor:pointer; box-shadow:0 10px 28px var(--shadow); }
1613
+ .mode strong { display:block; font-size:18px; margin-bottom:8px; font-weight:800; letter-spacing:-.01em; }
1614
+ .mode > span { display:block; color:var(--muted); line-height:1.45; }
1615
+ .mode.active { border-color:color-mix(in srgb, var(--accent) 60%, var(--line)); background:color-mix(in srgb, var(--accent) 9%, var(--surface-strong)); }
1616
+ .panel { display:none; padding:22px; border:1px solid var(--line); border-radius:8px; background:var(--surface-strong); box-shadow:0 14px 36px var(--shadow); }
1617
+ .panel.active { display:grid; gap:16px; }
1618
+ ol { margin:0; padding-left:22px; color:var(--muted); line-height:1.6; }
1619
+ code, pre { font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:13px; }
1620
+ pre { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; padding:14px; border:1px solid var(--line); border-radius:8px; background:rgba(28,22,10,.055); color:var(--text); }
1621
+ .actions, .row { display:flex; flex-wrap:wrap; gap:10px; }
1622
+ .fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
1623
+ label { display:grid; gap:6px; color:var(--muted); font-size:13px; }
1624
+ input, select { width:100%; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); border-radius:8px; padding:11px 12px; font:inherit; }
1625
+ .account-state { border:1px solid var(--line); border-radius:8px; background:var(--surface-muted); color:var(--muted); padding:12px 14px; font-size:13px; line-height:1.45; overflow-wrap:anywhere; }
1626
+ .server-grid { display:grid; grid-template-columns:minmax(180px,220px) 1fr; gap:10px; }
1627
+ button, a.button { appearance:none; border:1px solid var(--line-strong); background:var(--surface); color:var(--text); border-radius:8px; padding:10px 14px; font:inherit; font-weight:600; text-decoration:none; cursor:pointer; }
1628
+ button:hover, a.button:hover { border-color:var(--accent); }
1629
+ button.primary { background:var(--text); color:var(--bg); border-color:var(--text); }
1630
+ .status { min-height:18px; color:var(--muted); font-size:13px; }
1631
+ .status.info { color:var(--accent); }
1632
+ .status.ok { color:var(--good); }
1633
+ .status.error { color:#dc2626; }
1634
+ .meta { color:var(--soft); font-size:12px; }
1635
+ @media (max-width:820px) { main { grid-template-columns:1fr; gap:28px; padding:32px 0; } .mode-grid,.fields,.server-grid { grid-template-columns:1fr; } }
1636
+ html.empir3-chat-split-active main { grid-template-columns:1fr; gap:28px; padding:32px 0; }
1637
+ html.empir3-chat-split-active .mode-grid,
1638
+ html.empir3-chat-split-active .fields,
1639
+ html.empir3-chat-split-active .server-grid { grid-template-columns:1fr; }
1640
+ html.empir3-chat-split-active .wordmark-xl { font-size:56px; }
1641
+ html.empir3-chat-split-active h1 { max-width:15ch; font-size:30px; }
1642
+ </style>
1643
+ </head>
1644
+ <body>
1645
+ <main>
1646
+ <section class="brand">
1647
+ <div class="wordmark wordmark-xl" aria-label="empir3">empir<span class="three">3</span></div>
1648
+ <div class="product-kicker">Browser Bridge</div>
1649
+ <h1>Choose how this bridge should run</h1>
1650
+ <p class="lede">Use it locally through MCP, or pair it with an <span class="brand-inline">empir<span class="three">3</span></span> user account. This screen runs inside the controlled bridge window.</p>
1651
+ </section>
1652
+ <section class="shell">
1653
+ <div class="mode-grid">
1654
+ <button class="mode active" data-mode="mcp"><strong>MCP mode</strong><span>Claude Code, OpenAI, or another MCP client controls this local browser.</span></button>
1655
+ <button class="mode" data-mode="empir3"><strong><span class="brand-inline">empir<span class="three">3</span></span> user mode</strong><span>Store an <span class="brand-inline">empir<span class="three">3</span></span> user token on this bridge for future launches.</span></button>
1656
+ </div>
1657
+ <section class="panel active" id="panel-mcp">
1658
+ <h2>Use with Claude Code or OpenAI tools</h2>
1659
+ <p>MCP mode keeps everything local. Add this bridge as a stdio MCP server, then ask your client to use the <span class="brand-inline">empir<span class="three">3</span></span> Bridge tools.</p>
1660
+ <div class="actions"><button class="primary" id="loadMcp">Show MCP config</button><button id="copyMcp">Copy config</button></div>
1661
+ <pre id="mcpSnippet">Click "Show MCP config" to generate the command for this install.</pre>
1662
+ <ol id="mcpSteps"><li>For Claude Code, save the config as <code>.mcp.json</code>.</li><li>For OpenAI or another MCP client, use the same stdio server command.</li></ol>
1663
+ <p class="status" id="mcpStatus"></p>
1664
+ </section>
1665
+ <section class="panel" id="panel-empir3">
1666
+ <h2>Pair with an <span class="brand-inline">empir<span class="three">3</span></span> user</h2>
1667
+ <p>Use browser login if it is already the right account. Use direct login to override a stale or different browser session.</p>
1668
+ <div class="account-state" id="accountState">Checking stored bridge account...</div>
1669
+ <div class="server-grid">
1670
+ <label><span class="brand-inline">empir<span class="three">3</span></span> server
1671
+ <select id="serverPreset">
1672
+ <option value="production">Production - app.empir3.com</option>
1673
+ <option value="local-dev">Local dev - localhost:3005</option>
1674
+ <option value="custom">Custom server</option>
1675
+ </select>
1676
+ </label>
1677
+ <label id="customServerLabel">Server URL<input id="serverUrl" type="url" value="https://app.empir3.com"></label>
1678
+ </div>
1679
+ <div class="actions"><button class="primary" id="pairEmpir3">Use browser empir3 login</button><button id="signOutBridge" type="button">Sign out stored bridge account</button></div>
1680
+ <form id="loginForm">
1681
+ <div class="fields"><label>Email<input id="email" type="email" autocomplete="username"></label><label>Password<input id="password" type="password" autocomplete="current-password"></label></div>
1682
+ <div class="actions" style="margin-top:10px"><button type="submit">Sign in and store on this bridge</button></div>
1683
+ </form>
1684
+ <p class="status" id="pairStatus"></p>
1685
+ </section>
1686
+ <div class="meta">bridge ${PORT} - setup API ${WRAPPER_PORT}</div>
1687
+ </section>
1688
+ </main>
1689
+ <script>
1690
+ const API = ${JSON.stringify(api)};
1691
+ const PROD_SERVER = 'https://app.empir3.com';
1692
+ const DEV_SERVER = 'http://localhost:3005';
1693
+ const $ = (id) => document.getElementById(id);
1694
+ let mcpText = '';
1695
+ function setStatus(id, message, tone = 'info') {
1696
+ const el = $(id);
1697
+ if (!el) return;
1698
+ el.classList.remove('info', 'ok', 'error');
1699
+ el.classList.add(tone);
1700
+ el.textContent = message;
1701
+ }
1702
+ function selectedServer() {
1703
+ const preset = $('serverPreset')?.value || 'production';
1704
+ if (preset === 'production') return PROD_SERVER;
1705
+ if (preset === 'local-dev') return DEV_SERVER;
1706
+ return ($('serverUrl')?.value || PROD_SERVER).trim();
1707
+ }
1708
+ function syncServerUi(serverUrl) {
1709
+ const normalized = (serverUrl || PROD_SERVER).replace(/\\/+$/, '');
1710
+ if (normalized === PROD_SERVER) $('serverPreset').value = 'production';
1711
+ else if (normalized === DEV_SERVER) $('serverPreset').value = 'local-dev';
1712
+ else $('serverPreset').value = 'custom';
1713
+ $('serverUrl').value = normalized;
1714
+ $('customServerLabel').style.display = $('serverPreset').value === 'custom' ? 'grid' : 'none';
1715
+ }
1716
+ async function refreshAccountState() {
1717
+ try {
1718
+ const r = await fetch(API + '/api/relay-status');
1719
+ const j = await r.json();
1720
+ if (j.serverUrl) syncServerUi(j.serverUrl);
1721
+ const account = j.authUser?.email ? j.authUser.email : 'No stored empir3 account';
1722
+ const server = (j.serverUrl || PROD_SERVER).replace(/\\/+$/, '');
1723
+ const mode = j.mode || 'unknown';
1724
+ const relay = j.relay?.connected ? 'connected' : (j.hasAuth ? 'not connected yet' : 'not paired');
1725
+ $('accountState').textContent = account + ' - ' + server + ' - ' + mode + ' - ' + relay;
1726
+ } catch {
1727
+ $('accountState').textContent = 'Bridge daemon is reachable, but account status could not be read.';
1728
+ }
1729
+ }
1730
+ syncServerUi(PROD_SERVER);
1731
+ $('serverPreset').addEventListener('change', () => {
1732
+ const preset = $('serverPreset').value;
1733
+ if (preset === 'production') $('serverUrl').value = PROD_SERVER;
1734
+ if (preset === 'local-dev') $('serverUrl').value = DEV_SERVER;
1735
+ $('customServerLabel').style.display = preset === 'custom' ? 'grid' : 'none';
1736
+ });
1737
+ refreshAccountState();
1738
+ document.querySelectorAll('.mode').forEach((btn) => btn.addEventListener('click', () => {
1739
+ document.querySelectorAll('.mode').forEach((b) => b.classList.toggle('active', b === btn));
1740
+ document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
1741
+ $('panel-' + btn.dataset.mode).classList.add('active');
1742
+ }));
1743
+ $('loadMcp').addEventListener('click', async () => {
1744
+ setStatus('mcpStatus', 'Generating MCP config...', 'info');
1745
+ try {
1746
+ const r = await fetch(API + '/api/install/claude-code', { method:'POST' });
1747
+ const j = await r.json();
1748
+ if (!j.ok) throw new Error(j.error || 'could not generate config');
1749
+ mcpText = JSON.stringify(j.snippet, null, 2);
1750
+ $('mcpSnippet').textContent = mcpText;
1751
+ $('mcpSteps').innerHTML = (j.instructions || []).map((s) => '<li>' + s.replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c])) + '</li>').join('');
1752
+ setStatus('mcpStatus', 'MCP mode is selected for this bridge.', 'ok');
1753
+ } catch (e) { setStatus('mcpStatus', 'Could not generate MCP config: ' + e.message, 'error'); }
1754
+ });
1755
+ $('copyMcp').addEventListener('click', async () => {
1756
+ if (!mcpText) mcpText = $('mcpSnippet').textContent;
1757
+ try { await navigator.clipboard.writeText(mcpText); setStatus('mcpStatus', 'Config copied.', 'ok'); }
1758
+ catch { setStatus('mcpStatus', 'Select the config text and copy it manually.', 'error'); }
1759
+ });
1760
+ $('pairEmpir3').addEventListener('click', async () => {
1761
+ setStatus('pairStatus', 'Starting browser-based empir3 pairing...', 'info');
1762
+ try {
1763
+ const r = await fetch(API + '/api/install/empir3-pair', {
1764
+ method:'POST',
1765
+ headers:{'Content-Type':'application/json'},
1766
+ body:JSON.stringify({ serverUrl:selectedServer() })
1767
+ });
1768
+ const j = await r.json();
1769
+ if (!j.ok || !j.redirectUrl) throw new Error(j.error || 'pairing failed');
1770
+ setStatus('pairStatus', 'Pairing code ' + j.code + ' created. Opening empir3...', 'ok');
1771
+ setTimeout(() => { location.href = j.redirectUrl; }, 400);
1772
+ } catch (e) { setStatus('pairStatus', 'Could not start pairing: ' + e.message, 'error'); }
1773
+ });
1774
+ $('signOutBridge').addEventListener('click', async () => {
1775
+ setStatus('pairStatus', 'Signing out stored bridge account...', 'info');
1776
+ try {
1777
+ const r = await fetch(API + '/api/install/sign-out', { method:'POST' });
1778
+ const j = await r.json();
1779
+ if (!j.ok) throw new Error(j.error || 'sign out failed');
1780
+ setStatus('pairStatus', 'Signed out. Restarting the bridge...', 'ok');
1781
+ } catch (e) { setStatus('pairStatus', 'Could not sign out: ' + e.message, 'error'); }
1782
+ });
1783
+ $('loginForm').addEventListener('submit', async (e) => {
1784
+ e.preventDefault();
1785
+ setStatus('pairStatus', 'Signing this bridge into empir3...', 'info');
1786
+ try {
1787
+ const r = await fetch(API + '/api/install/empir3-login', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ email:$('email').value, password:$('password').value, serverUrl:selectedServer() }) });
1788
+ const j = await r.json();
1789
+ if (!j.ok) throw new Error(j.error || 'login failed');
1790
+ setStatus('pairStatus', 'Signed in. Restarting the bridge...', 'ok');
1791
+ } catch (e) { setStatus('pairStatus', 'Could not sign in: ' + e.message, 'error'); }
1792
+ });
1793
+ </script>
1794
+ </body>
1795
+ </html>`;
1796
+ }
1797
+
1798
+ // ─── HTTP Server ─────────────────────────────────────────────
1799
+
1800
+ function parseBody(req: IncomingMessage): Promise<any> {
1801
+ return new Promise((resolve) => {
1802
+ let body = '';
1803
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1804
+ req.on('end', () => {
1805
+ try { resolve(JSON.parse(body)); }
1806
+ catch { resolve({}); }
1807
+ });
1808
+ });
1809
+ }
1810
+
1811
+ function sendJSON(res: ServerResponse, data: any, status = 200) {
1812
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
1813
+ res.end(JSON.stringify(data));
1814
+ }
1815
+
1816
+ function sendError(res: ServerResponse, msg: string, status = 500) {
1817
+ sendJSON(res, { error: msg }, status);
1818
+ }
1819
+
1820
+ function readRawBody(req: IncomingMessage): Promise<Buffer> {
1821
+ return new Promise((resolve, reject) => {
1822
+ const chunks: Buffer[] = [];
1823
+ req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
1824
+ req.on('end', () => resolve(Buffer.concat(chunks)));
1825
+ req.on('error', reject);
1826
+ });
1827
+ }
1828
+
1829
+ async function proxyWrapperRequest(req: IncomingMessage, res: ServerResponse, targetPath: string) {
1830
+ const method = req.method || 'GET';
1831
+ const body = method === 'GET' || method === 'HEAD' ? Buffer.alloc(0) : await readRawBody(req);
1832
+ const headers: Record<string, string> = {
1833
+ 'Accept': 'application/json',
1834
+ };
1835
+ const contentType = req.headers['content-type'];
1836
+ if (contentType) headers['Content-Type'] = Array.isArray(contentType) ? contentType[0] : contentType;
1837
+ if (body.length) headers['Content-Length'] = String(body.length);
1838
+
1839
+ await new Promise<void>((resolve) => {
1840
+ const proxy = httpRequest({
1841
+ hostname: '127.0.0.1',
1842
+ port: WRAPPER_PORT,
1843
+ path: targetPath,
1844
+ method,
1845
+ headers,
1846
+ }, (proxyRes) => {
1847
+ const responseHeaders = {
1848
+ ...proxyRes.headers,
1849
+ 'Access-Control-Allow-Origin': '*',
1850
+ };
1851
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
1852
+ proxyRes.pipe(res);
1853
+ proxyRes.on('end', resolve);
1854
+ });
1855
+ proxy.on('error', (e: Error) => {
1856
+ if (!res.headersSent) {
1857
+ sendJSON(res, { ok: false, error: `Bridge setup API unavailable: ${e.message}` }, 502);
1858
+ } else {
1859
+ res.end();
1860
+ }
1861
+ resolve();
1862
+ });
1863
+ if (body.length) proxy.write(body);
1864
+ proxy.end();
1865
+ });
1866
+ }
1867
+
1868
+ function checkAuth(req: IncomingMessage): boolean {
1869
+ if (!SESSION_TOKEN) return true; // No token configured = no auth
1870
+ const auth = req.headers.authorization || '';
1871
+ return auth === `Bearer ${SESSION_TOKEN}`;
1872
+ }
1873
+
1874
+ async function handleRequest(req: IncomingMessage, res: ServerResponse) {
1875
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
1876
+ const path = url.pathname;
1877
+ const method = req.method || 'GET';
1878
+
1879
+ // CORS preflight
1880
+ if (method === 'OPTIONS') {
1881
+ res.writeHead(204, {
1882
+ 'Access-Control-Allow-Origin': '*',
1883
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1884
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1885
+ });
1886
+ res.end();
1887
+ return;
1888
+ }
1889
+
1890
+ // Welcome page — no auth needed
1891
+ if (path === '/welcome') {
1892
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1893
+ res.end(getWelcomeHtml());
1894
+ return;
1895
+ }
1896
+
1897
+ // Health — no auth needed
1898
+ if (path === '/health') {
1899
+ const hasOpenCdpSocket = !!cdpWs && cdpWs.readyState === WebSocket.OPEN;
1900
+ const hasRecentCdpCommand = connected && lastCdpLivenessAt > 0 && Date.now() - lastCdpLivenessAt < 15000;
1901
+ const hasKnownPage = !!currentTargetId || knownTargets.size > 0;
1902
+ const cdpConnected = !chromeClosedByUser && chromeStatus() === 'running' && (hasOpenCdpSocket || hasRecentCdpCommand || hasKnownPage);
1903
+ sendJSON(res, {
1904
+ status: cdpConnected ? 'connected' : 'disconnected',
1905
+ port: PORT,
1906
+ cdpPort: CDP_PORT,
1907
+ chrome: chromeStatus(),
1908
+ closedByUser: chromeClosedByUser,
1909
+ cdpConnected,
1910
+ });
1911
+ return;
1912
+ }
1913
+
1914
+ if (
1915
+ path === '/api/relay-status' ||
1916
+ path === '/api/command' ||
1917
+ path === '/api/install/claude-code' ||
1918
+ path === '/api/install/empir3-pair' ||
1919
+ path === '/api/install/empir3-login' ||
1920
+ path === '/api/install/sign-out'
1921
+ ) {
1922
+ await proxyWrapperRequest(req, res, path + url.search);
1923
+ return;
1924
+ }
1925
+
1926
+ // Auth check for everything else
1927
+ if (!checkAuth(req)) {
1928
+ sendError(res, 'Unauthorized', 401);
1929
+ return;
1930
+ }
1931
+
1932
+ try {
1933
+ // ── GET endpoints ──────────────────────────
1934
+
1935
+ if (method === 'GET' && path === '/screenshot') {
1936
+ await ensureChromeReady();
1937
+ const quality = parseInt(url.searchParams.get('quality') || '80');
1938
+ const maxWidth = url.searchParams.get('maxWidth') ? parseInt(url.searchParams.get('maxWidth')!) : undefined;
1939
+ const params: any = { format: 'jpeg', quality };
1940
+ if (maxWidth && maxWidth > 0) {
1941
+ try {
1942
+ const evalResult = await cdpSend('Runtime.evaluate', {
1943
+ expression: 'JSON.stringify({w:window.innerWidth,h:window.innerHeight,dpr:window.devicePixelRatio||1})',
1944
+ returnByValue: true,
1945
+ });
1946
+ const vp = JSON.parse(evalResult.result.value);
1947
+ if (vp.w > 0 && vp.h > 0 && vp.dpr > 0) {
1948
+ const physicalWidth = vp.w * vp.dpr;
1949
+ if (physicalWidth > maxWidth) {
1950
+ const scale = Math.max(0.1, Math.min(2.0, maxWidth / physicalWidth));
1951
+ params.clip = { x: 0, y: 0, width: vp.w, height: vp.h, scale };
1952
+ }
1953
+ }
1954
+ } catch {}
1955
+ }
1956
+ const result = await captureScreenshot(params);
1957
+ const buf = Buffer.from(result.data, 'base64');
1958
+ if (url.searchParams.get('format') === 'json') {
1959
+ // Only return JSON if explicitly requested
1960
+ sendJSON(res, { data: result.data, format: 'jpeg' });
1961
+ } else {
1962
+ // Default: return raw JPEG bytes (works with ?raw=true for backwards compat)
1963
+ res.writeHead(200, {
1964
+ 'Content-Type': 'image/jpeg',
1965
+ 'Content-Length': buf.length.toString(),
1966
+ 'Access-Control-Allow-Origin': '*',
1967
+ });
1968
+ res.end(buf);
1969
+ }
1970
+ return;
1971
+ }
1972
+
1973
+ if (method === 'GET' && path === '/snapshot') {
1974
+ await ensureChromeReady();
1975
+ const filter = url.searchParams.get('filter') || 'interactive';
1976
+ const nodes = await getAccessibilityTree(filter);
1977
+ sendJSON(res, { count: nodes.length, nodes });
1978
+ return;
1979
+ }
1980
+
1981
+ if (method === 'GET' && path === '/text') {
1982
+ await ensureChromeReady();
1983
+ const text = await cdpEvaluate(`(function() {
1984
+ const title = document.title || '';
1985
+ // The bridge injects its overlay UI (chat sidebar, toolbar, ghost
1986
+ // cursor, etc.) into the page's light DOM under id="empir3-*" roots.
1987
+ // Its text ("Bridge Disconnected / Snap / Draw / Send / …") otherwise
1988
+ // pollutes innerText and breaks downstream parsing for agents. Detach
1989
+ // the top-level overlay roots, read innerText (forces a synchronous
1990
+ // reflow without them), then restore them in place — all within one JS
1991
+ // turn so there is no visible flicker and overlay state is preserved.
1992
+ const roots = Array.prototype.slice.call(document.querySelectorAll('[id^="empir3-"]'))
1993
+ .filter(function(el){ return !(el.parentElement && el.parentElement.closest('[id^="empir3-"]')); });
1994
+ const saved = roots.map(function(el){ return { el: el, parent: el.parentNode, next: el.nextSibling }; });
1995
+ saved.forEach(function(s){ if (s.parent) s.parent.removeChild(s.el); });
1996
+ let body = '';
1997
+ try { body = document.body ? document.body.innerText : ''; }
1998
+ finally {
1999
+ saved.forEach(function(s){
2000
+ if (!s.parent) return;
2001
+ if (s.next && s.next.parentNode === s.parent) s.parent.insertBefore(s.el, s.next);
2002
+ else s.parent.appendChild(s.el);
2003
+ });
2004
+ }
2005
+ return JSON.stringify({ title, text: body.slice(0, 50000) });
2006
+ })()`);
2007
+ sendJSON(res, JSON.parse(text));
2008
+ return;
2009
+ }
2010
+
2011
+ if (method === 'GET' && path === '/tabs') {
2012
+ let targets: any[] = [];
2013
+ try {
2014
+ targets = await Promise.race([
2015
+ fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`),
2016
+ sleep(1500).then(() => null),
2017
+ ]) as any[] | null || [];
2018
+ } catch {
2019
+ sendJSON(res, { tabs: [], currentTargetId: '', chrome: chromeStatus(), closedByUser: chromeClosedByUser });
2020
+ return;
2021
+ }
2022
+ if (!targets.length && knownTargets.size > 0) {
2023
+ const tabs = Array.from(knownTargets.entries()).map(([id, url]) => ({
2024
+ id,
2025
+ title: '',
2026
+ url,
2027
+ type: 'page',
2028
+ active: id === currentTargetId,
2029
+ }));
2030
+ sendJSON(res, { tabs, currentTargetId, chrome: chromeStatus(), closedByUser: chromeClosedByUser });
2031
+ return;
2032
+ }
2033
+ const tabs = targets
2034
+ .filter((t: any) => t.type === 'page')
2035
+ .map((t: any) => ({
2036
+ id: t.id,
2037
+ title: t.title,
2038
+ url: t.url,
2039
+ type: t.type,
2040
+ active: t.id === currentTargetId,
2041
+ }));
2042
+ sendJSON(res, { tabs, currentTargetId, chrome: chromeStatus(), closedByUser: chromeClosedByUser });
2043
+ return;
2044
+ }
2045
+
2046
+ // ── POST endpoints ─────────────────────────
2047
+
2048
+ if (method === 'POST') {
2049
+ const body = await parseBody(req);
2050
+
2051
+ // Evaluate JS on a specific target without switching active tab
2052
+ if (path === '/evaluate-on-target') {
2053
+ const { targetId, expression } = body;
2054
+ if (!targetId || !expression) throw new Error('targetId and expression required');
2055
+ const timeoutMs = Math.max(250, Math.min(10000, Number(body.timeoutMs) || CDP_COMMAND_TIMEOUT_MS));
2056
+ const result = await evaluateOnTarget(targetId, expression, timeoutMs);
2057
+ sendJSON(res, { ok: true, result });
2058
+ return;
2059
+ }
2060
+
2061
+ // Evaluate JS on ALL page targets (used for overlay injection)
2062
+ if (path === '/evaluate-all') {
2063
+ const { expression } = body;
2064
+ if (!expression) throw new Error('expression required');
2065
+ const timeoutMs = Math.max(250, Math.min(10000, Number(body.timeoutMs) || CDP_COMMAND_TIMEOUT_MS));
2066
+ const results = await evaluateOnAllTargets(expression, timeoutMs);
2067
+ sendJSON(res, { ok: true, results });
2068
+ return;
2069
+ }
2070
+
2071
+ // Register a script to auto-inject into every new tab
2072
+ if (path === '/register-auto-inject') {
2073
+ autoInjectScript = body.script || '';
2074
+ // Immediately inject into all existing tabs
2075
+ let results: any[] = [];
2076
+ if (autoInjectScript) {
2077
+ results = await evaluateOnAllTargets(autoInjectScript);
2078
+ startTargetPolling();
2079
+ } else {
2080
+ stopTargetPolling();
2081
+ }
2082
+ sendJSON(res, { ok: true, registered: !!autoInjectScript, injected: results });
2083
+ return;
2084
+ }
2085
+
2086
+ if (path === '/activate-target') {
2087
+ await ensureChromeReady({ allowRelaunch: false });
2088
+ const targetId = typeof body.targetId === 'string' ? body.targetId : '';
2089
+ if (!targetId) throw new Error('activate-target requires targetId');
2090
+ await switchToTarget(targetId);
2091
+ if (body.bringToFront !== false) {
2092
+ try { await cdpSend('Page.bringToFront'); } catch {}
2093
+ }
2094
+ const target = await currentPageTarget();
2095
+ let url = String(target.url || '');
2096
+ let title = String(target.title || '');
2097
+ try { url = await cdpEvaluate('location.href', 1200) || url; } catch {}
2098
+ try { title = await cdpEvaluate('document.title', 1200) || title; } catch {}
2099
+ sendJSON(res, { ok: true, targetId: currentTargetId, url, title });
2100
+ return;
2101
+ }
2102
+
2103
+ if (path === '/show') {
2104
+ const url = typeof body.url === 'string' ? body.url : undefined;
2105
+ const shownUrl = await showChromeWindow(url);
2106
+ sendJSON(res, { ok: true, shown: true, url: shownUrl });
2107
+ return;
2108
+ }
2109
+
2110
+ if (path === '/navigate') {
2111
+ await ensureChromeReady({ allowRelaunch: true });
2112
+ const targetUrl = typeof body.url === 'string' && body.url.trim() ? body.url.trim() : '';
2113
+ if (!targetUrl) throw new Error('navigate requires a non-empty url');
2114
+ // Check if URL is already open in another tab
2115
+ const targets = await fetchJSON(`http://127.0.0.1:${CDP_PORT}/json`);
2116
+ const normalizeUrl = (u: string) => u.replace(/[#/]+$/, '');
2117
+ const existing = targets.find((t: any) =>
2118
+ t.type === 'page' && normalizeUrl(String(t.url || '')) === normalizeUrl(targetUrl) && t.id !== currentTargetId
2119
+ );
2120
+ if (existing) {
2121
+ // Switch to existing tab
2122
+ await switchToTarget(existing.id);
2123
+ // Activate the tab visually
2124
+ await cdpSend('Page.bringToFront');
2125
+ } else {
2126
+ await cdpNavigate(targetUrl);
2127
+ }
2128
+ let currentUrl = '';
2129
+ let title = '';
2130
+ try { currentUrl = await cdpEvaluate('location.href', 1200); } catch {}
2131
+ try { title = await cdpEvaluate('document.title', 1200); } catch {}
2132
+ if (!currentUrl || !title) {
2133
+ const target = await currentPageTarget();
2134
+ currentUrl = currentUrl || String(target.url || targetUrl);
2135
+ title = title || String(target.title || '');
2136
+ }
2137
+ sendJSON(res, { title, url: currentUrl });
2138
+ return;
2139
+ }
2140
+
2141
+ if (path === '/action') {
2142
+ await ensureChromeReady();
2143
+ const kind = body.kind;
2144
+
2145
+ switch (kind) {
2146
+ case 'click':
2147
+ if (body.ref) {
2148
+ await clickByRef(body.ref);
2149
+ } else if (body.selector) {
2150
+ await cdpEvaluate(`document.querySelector(${JSON.stringify(body.selector)})?.click()`);
2151
+ } else if (typeof body.x === 'number' && typeof body.y === 'number') {
2152
+ await clickByXY(body.x, body.y);
2153
+ }
2154
+ sendJSON(res, { success: true });
2155
+ break;
2156
+
2157
+ case 'type': {
2158
+ const typeTarget = body.ref
2159
+ ? `[data-empir3-ref="${body.ref}"]`
2160
+ : body.selector || null;
2161
+
2162
+ if (typeTarget) {
2163
+ // Use native value setter + input/change events (works with React, Vue, plain HTML)
2164
+ await cdpEvaluate(`(function() {
2165
+ const el = document.querySelector(${JSON.stringify(typeTarget)});
2166
+ if (!el) return 'not_found';
2167
+ el.scrollIntoViewIfNeeded ? el.scrollIntoViewIfNeeded() : el.scrollIntoView();
2168
+ el.focus();
2169
+ if (el.isContentEditable) {
2170
+ document.execCommand('selectAll', false, null);
2171
+ document.execCommand('insertText', false, ${JSON.stringify(body.text || '')});
2172
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2173
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2174
+ return 'typed_contenteditable';
2175
+ }
2176
+ const isTextarea = el.tagName === 'TEXTAREA';
2177
+ const proto = isTextarea ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
2178
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
2179
+ if (setter) {
2180
+ const tracker = el._valueTracker;
2181
+ if (tracker) tracker.setValue('');
2182
+ setter.call(el, ${JSON.stringify(body.text || '')});
2183
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2184
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2185
+ return 'typed';
2186
+ }
2187
+ if ('value' in el) {
2188
+ el.value = ${JSON.stringify(body.text || '')};
2189
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2190
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2191
+ return 'typed_basic';
2192
+ }
2193
+ el.textContent = ${JSON.stringify(body.text || '')};
2194
+ el.dispatchEvent(new Event('input', { bubbles: true }));
2195
+ el.dispatchEvent(new Event('change', { bubbles: true }));
2196
+ return 'typed_basic';
2197
+ })()`);
2198
+ } else {
2199
+ // No target — type into whatever is focused via keyboard events
2200
+ await typeText(body.text || '');
2201
+ }
2202
+ sendJSON(res, { success: true });
2203
+ break;
2204
+ }
2205
+
2206
+ case 'selectAll':
2207
+ await pressKey('Control+a');
2208
+ sendJSON(res, { success: true });
2209
+ break;
2210
+
2211
+ case 'press':
2212
+ await pressKey(body.key || body.text || '');
2213
+ sendJSON(res, { success: true });
2214
+ break;
2215
+
2216
+ case 'scroll': {
2217
+ const dx = Number(body.x || 0);
2218
+ const dy = Number(body.y || 0);
2219
+ const result = await cdpEvaluate(`(function(){
2220
+ var dx=${JSON.stringify(dx)},dy=${JSON.stringify(dy)};
2221
+ function pickScroller(axis){
2222
+ var cands=[document.scrollingElement,document.documentElement,document.body];
2223
+ for(var i=0;i<cands.length;i++){
2224
+ var el=cands[i]; if(!el) continue;
2225
+ if(axis==='y' && el.scrollHeight>el.clientHeight) return el;
2226
+ if(axis==='x' && el.scrollWidth>el.clientWidth) return el;
2227
+ }
2228
+ var all=document.querySelectorAll('*');
2229
+ for(var j=0;j<all.length;j++){
2230
+ var el2=all[j], s=getComputedStyle(el2);
2231
+ if(axis==='y' && (s.overflowY==='auto'||s.overflowY==='scroll') && el2.scrollHeight>el2.clientHeight) return el2;
2232
+ if(axis==='x' && (s.overflowX==='auto'||s.overflowX==='scroll') && el2.scrollWidth>el2.clientWidth) return el2;
2233
+ }
2234
+ return document.scrollingElement||document.documentElement;
2235
+ }
2236
+ var scY=pickScroller('y'), scX=dx?pickScroller('x'):scY;
2237
+ var prevBehaviorHtml=document.documentElement.style.scrollBehavior;
2238
+ document.documentElement.style.scrollBehavior='auto';
2239
+ var beforeX=(scX&&scX.scrollLeft)||0,beforeY=(scY&&scY.scrollTop)||0;
2240
+ if(scY) scY.scrollTop += dy;
2241
+ if(scX && dx) scX.scrollLeft += dx;
2242
+ var afterX=(scX&&scX.scrollLeft)||0,afterY=(scY&&scY.scrollTop)||0;
2243
+ var maxX=scX?Math.max(0,(scX.scrollWidth||0)-(scX.clientWidth||window.innerWidth||0)):0;
2244
+ var maxY=scY?Math.max(0,(scY.scrollHeight||0)-(scY.clientHeight||window.innerHeight||0)):0;
2245
+ document.documentElement.style.scrollBehavior=prevBehaviorHtml;
2246
+ return JSON.stringify({
2247
+ requested:{x:dx,y:dy},
2248
+ before:{x:beforeX,y:beforeY},
2249
+ after:{x:afterX,y:afterY},
2250
+ delta:{x:afterX-beforeX,y:afterY-beforeY},
2251
+ max:{x:maxX,y:maxY},
2252
+ canScroll:maxX>0||maxY>0,
2253
+ moved:afterX!==beforeX||afterY!==beforeY
2254
+ });
2255
+ })()`);
2256
+ let scroll: any = result;
2257
+ try { scroll = JSON.parse(result); } catch {}
2258
+ sendJSON(res, { success: true, position: scroll?.after || scroll, scroll, moved: scroll?.moved === true });
2259
+ break;
2260
+ }
2261
+
2262
+ case 'focus':
2263
+ if (body.ref) {
2264
+ await cdpEvaluate(`(function() {
2265
+ const el = document.querySelector('[data-empir3-ref="${body.ref}"]');
2266
+ if (el) el.focus();
2267
+ })()`);
2268
+ }
2269
+ sendJSON(res, { success: true });
2270
+ break;
2271
+
2272
+ case 'hover':
2273
+ if (body.ref) {
2274
+ const bounds = await cdpEvaluate(`(function() {
2275
+ const el = document.querySelector('[data-empir3-ref="${body.ref}"]');
2276
+ if (!el) return null;
2277
+ const r = el.getBoundingClientRect();
2278
+ return JSON.stringify({ x: r.x + r.width/2, y: r.y + r.height/2 });
2279
+ })()`);
2280
+ if (bounds) {
2281
+ const { x, y } = JSON.parse(bounds);
2282
+ await cdpSend('Input.dispatchMouseEvent', {
2283
+ type: 'mouseMoved', x, y,
2284
+ });
2285
+ }
2286
+ }
2287
+ sendJSON(res, { success: true });
2288
+ break;
2289
+
2290
+ default:
2291
+ sendError(res, `Unknown action: ${kind}`, 400);
2292
+ }
2293
+ return;
2294
+ }
2295
+
2296
+ if (path === '/evaluate') {
2297
+ await ensureChromeReady();
2298
+ const result = await cdpEvaluate(body.expression);
2299
+ sendJSON(res, { result });
2300
+ return;
2301
+ }
2302
+
2303
+ if (path === '/cookies') {
2304
+ await ensureChromeReady();
2305
+ for (const cookie of (body.cookies || [])) {
2306
+ await cdpSend('Network.setCookie', {
2307
+ name: cookie.name,
2308
+ value: cookie.value,
2309
+ url: body.url,
2310
+ domain: cookie.domain,
2311
+ path: cookie.path || '/',
2312
+ });
2313
+ }
2314
+ sendJSON(res, { success: true });
2315
+ return;
2316
+ }
2317
+ }
2318
+
2319
+ sendError(res, `Not found: ${method} ${path}`, 404);
2320
+ } catch (e: any) {
2321
+ console.error(`[Empir3 Bridge] Error: ${e.message}`);
2322
+ sendError(res, e.message);
2323
+ }
2324
+ }
2325
+
2326
+ // ─── Utilities ───────────────────────────────────────────────
2327
+
2328
+ function sleep(ms: number): Promise<void> {
2329
+ return new Promise(r => setTimeout(r, ms));
2330
+ }
2331
+
2332
+ async function fetchJSON(url: string, method: string = 'GET'): Promise<any> {
2333
+ const { default: http } = await import('http');
2334
+ const { URL } = await import('url');
2335
+ return new Promise((resolve, reject) => {
2336
+ let settled = false;
2337
+ const u = new URL(url);
2338
+ const req = http.request({
2339
+ hostname: u.hostname,
2340
+ port: u.port,
2341
+ // Chrome's /json/new takes the URL as the raw query string (not encoded),
2342
+ // so preserve u.search verbatim. http.request would re-serialize if we
2343
+ // passed pathname/search separately.
2344
+ path: u.pathname + u.search,
2345
+ method,
2346
+ }, (res) => {
2347
+ let data = '';
2348
+ res.on('data', (chunk: Buffer) => { data += chunk; });
2349
+ res.on('end', () => {
2350
+ if (settled) return;
2351
+ settled = true;
2352
+ try { resolve(JSON.parse(data)); }
2353
+ catch { reject(new Error(`Invalid JSON from ${url}`)); }
2354
+ });
2355
+ });
2356
+ req.setTimeout(3000, () => {
2357
+ if (settled) return;
2358
+ settled = true;
2359
+ req.destroy();
2360
+ reject(new Error(`Timeout fetching ${url}`));
2361
+ });
2362
+ req.on('error', (e) => {
2363
+ if (settled) return;
2364
+ settled = true;
2365
+ reject(e);
2366
+ });
2367
+ req.end();
2368
+ });
2369
+ }
2370
+
2371
+ // ─── Shutdown ────────────────────────────────────────────────
2372
+
2373
+ function shutdown() {
2374
+ console.log('[Empir3 Bridge] Shutting down...');
2375
+ shuttingDown = true;
2376
+ stopTargetPolling();
2377
+ if (browserWs) { try { browserWs.close(); } catch {} }
2378
+ if (cdpWs) cdpWs.close();
2379
+ if (chromeProcess) {
2380
+ chromeProcess.kill();
2381
+ }
2382
+ process.exit(0);
2383
+ }
2384
+
2385
+ process.on('SIGINT', shutdown);
2386
+ process.on('SIGTERM', shutdown);
2387
+
2388
+ // ─── Main ────────────────────────────────────────────────────
2389
+
2390
+ async function main() {
2391
+ const server = createServer(handleRequest);
2392
+
2393
+ const ownsHttpPort = await new Promise<boolean>((resolve, reject) => {
2394
+ let settled = false;
2395
+ server.once('error', (e: NodeJS.ErrnoException) => {
2396
+ if (settled) return;
2397
+ settled = true;
2398
+ if (e.code === 'EADDRINUSE') {
2399
+ console.warn(`[Empir3 Bridge] HTTP server ${HOST}:${PORT} is already in use; using the existing bridge if its /health check passes.`);
2400
+ resolve(false);
2401
+ return;
2402
+ }
2403
+ reject(e);
2404
+ });
2405
+ server.listen(PORT, HOST, () => {
2406
+ if (settled) return;
2407
+ settled = true;
2408
+ console.log(`[Empir3 Bridge] HTTP server on ${HOST}:${PORT}`);
2409
+ resolve(true);
2410
+ });
2411
+ });
2412
+
2413
+ if (!ownsHttpPort) return;
2414
+
2415
+ // Launch Chrome
2416
+ try {
2417
+ await launchChrome();
2418
+ console.log(`[Empir3 Bridge] Ready — Chrome connected via CDP on port ${CDP_PORT}`);
2419
+ startTargetPolling();
2420
+ // Connect browser-level WS for real-time target discovery (sees ALL tabs)
2421
+ connectBrowserWs().catch(() => {});
2422
+ } catch (e: any) {
2423
+ console.error(`[Empir3 Bridge] Failed to launch Chrome: ${e.message}`);
2424
+ console.log('[Empir3 Bridge] Waiting for Chrome to connect...');
2425
+
2426
+ // Poll for Chrome
2427
+ const poll = async () => {
2428
+ try {
2429
+ await connectCDP();
2430
+ console.log('[Empir3 Bridge] Chrome connected');
2431
+ startTargetPolling();
2432
+ connectBrowserWs().catch(() => {});
2433
+ } catch {
2434
+ setTimeout(poll, 3000);
2435
+ }
2436
+ };
2437
+ poll();
2438
+ }
2439
+ }
2440
+
2441
+ main().catch((e) => {
2442
+ console.error('[Empir3 Bridge] Fatal:', e);
2443
+ process.exit(1);
2444
+ });