gazesight 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -3,13 +3,13 @@
3
3
  // apps/cli/src/index.ts
4
4
  import { existsSync } from "node:fs";
5
5
  import { spawnSync } from "node:child_process";
6
- import { createInterface } from "node:readline/promises";
6
+ import { createInterface as createInterface2 } from "node:readline/promises";
7
7
  import { resolve, dirname as dirname2, join as join2, delimiter } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
 
10
10
  // apps/cli/src/config.ts
11
11
  import { randomBytes } from "node:crypto";
12
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
 
@@ -22,6 +22,17 @@ var agentGuidanceSchema = z.strictObject({
22
22
  var localConfigSchema = z.strictObject({
23
23
  token: z.string().regex(/^[a-f0-9]{64}$/),
24
24
  port: z.number().int().min(1024).max(65535),
25
+ pairings: z.array(
26
+ z.strictObject({
27
+ id: z.string().uuid(),
28
+ browser: z.enum(["firefox", "chrome"]),
29
+ extensionId: z.string().min(1).max(256),
30
+ origin: z.string().min(1).max(300),
31
+ credentialHash: z.string().regex(/^[a-f0-9]{64}$/),
32
+ createdAt: z.string().datetime(),
33
+ revokedAt: z.string().datetime().optional()
34
+ })
35
+ ).max(32).default([]),
25
36
  allowedOrigins: z.array(z.url()).default([]),
26
37
  agentGuidance: agentGuidanceSchema.default({
27
38
  enabled: true,
@@ -81,9 +92,17 @@ function setupConfig(path, port) {
81
92
  }
82
93
  return config;
83
94
  }
95
+ function writeLocalConfig(path, value) {
96
+ const config = localConfigSchema.parse(value);
97
+ mkdirSync(dirname(path), { recursive: true });
98
+ const temporary = `${path}.${process.pid}.tmp`;
99
+ writeFileSync(temporary, JSON.stringify(config, null, 2), { mode: 384 });
100
+ renameSync(temporary, path);
101
+ return config;
102
+ }
84
103
 
85
104
  // packages/protocol/src/release.ts
86
- var VERSION = "0.3.5";
105
+ var VERSION = "0.4.0";
87
106
 
88
107
  // apps/cli/src/doctor.ts
89
108
  import { z as z2 } from "zod";
@@ -102,12 +121,12 @@ function diagnosticRows(status, reachable = false, authenticated = false) {
102
121
  {
103
122
  name: "extension connected",
104
123
  pass: !!status?.connected,
105
- action: "Load the Firefox or Chrome extension and import your local config in its extension options."
124
+ action: status?.authorizedPairings ? "An extension is paired but disconnected. Start it and verify the configured bridge port." : "Open Firefox or Chrome, then click Connect to GazeSight in the extension."
106
125
  },
107
126
  {
108
127
  name: "pairing",
109
128
  pass: authenticated && !!status?.paired,
110
- action: "Import the same local configuration into Firefox options; verify port and pairing."
129
+ action: "Run gazesight pair, click Connect to GazeSight, and approve the matching code."
111
130
  },
112
131
  {
113
132
  name: "target selected",
@@ -145,9 +164,1208 @@ var statusSchema = z2.object({
145
164
  connected: z2.boolean(),
146
165
  paired: z2.boolean(),
147
166
  targetState: z2.enum(["none", "authorized", "reauthorization_required"]),
148
- backgroundCapture: z2.object({ enabled: z2.boolean(), permissionGranted: z2.boolean() }).optional()
167
+ backgroundCapture: z2.object({ enabled: z2.boolean(), permissionGranted: z2.boolean() }).optional(),
168
+ authorizedPairings: z2.number().int().nonnegative().optional()
149
169
  });
150
170
 
171
+ // apps/cli/src/pair.ts
172
+ import { createInterface } from "node:readline/promises";
173
+
174
+ // apps/mcp-server/src/bridge/websocket-server.ts
175
+ import { WebSocketServer, WebSocket } from "ws";
176
+
177
+ // packages/protocol/src/schemas.ts
178
+ import { z as z3 } from "zod";
179
+ var targetSchema = z3.strictObject({
180
+ tabId: z3.number().int().nonnegative().optional()
181
+ });
182
+ var stabilityDiagnosticsSchema = z3.strictObject({
183
+ stable: z3.boolean(),
184
+ stabilityWaitMs: z3.number().finite().nonnegative(),
185
+ lastMutationAgoMs: z3.number().finite().nonnegative(),
186
+ fontsReady: z3.boolean(),
187
+ pendingImages: z3.number().int().nonnegative().max(1e6),
188
+ timeoutReached: z3.boolean()
189
+ });
190
+ var diagnosticsQuerySchema = targetSchema.extend({
191
+ maxEntries: z3.number().int().min(1).max(100).default(50),
192
+ reset: z3.boolean().default(true)
193
+ });
194
+ var highlightSchema = targetSchema.extend({
195
+ selector: z3.string().min(1).max(2048),
196
+ durationMs: z3.number().int().min(500).max(3e4).default(3e3)
197
+ });
198
+ var highlightResultSchema = z3.strictObject({
199
+ tabId: z3.number().int().nonnegative(),
200
+ url: z3.url(),
201
+ highlighted: z3.boolean()
202
+ });
203
+ var consoleEntrySchema = z3.strictObject({
204
+ level: z3.enum(["warn", "error"]),
205
+ message: z3.string().max(512),
206
+ timestamp: z3.number().finite().nonnegative()
207
+ });
208
+ var networkEntrySchema = z3.strictObject({
209
+ url: z3.url().max(1024),
210
+ method: z3.string().max(16),
211
+ resourceType: z3.string().max(32),
212
+ status: z3.number().int().min(100).max(599).optional(),
213
+ error: z3.string().max(128).optional(),
214
+ timestamp: z3.number().finite().nonnegative()
215
+ });
216
+ var consoleResultSchema = z3.strictObject({
217
+ tabId: z3.number().int().nonnegative(),
218
+ entries: z3.array(consoleEntrySchema).max(100),
219
+ dropped: z3.number().int().nonnegative(),
220
+ forwardingDropped: z3.number().int().nonnegative()
221
+ });
222
+ var networkResultSchema = z3.strictObject({
223
+ tabId: z3.number().int().nonnegative(),
224
+ entries: z3.array(networkEntrySchema).max(100),
225
+ dropped: z3.number().int().nonnegative()
226
+ });
227
+ var pageStatusContentSchema = z3.strictObject({
228
+ url: z3.url(),
229
+ title: z3.string().max(160),
230
+ readyState: z3.enum(["loading", "interactive", "complete"]),
231
+ viewport: z3.strictObject({
232
+ width: z3.number().int().positive(),
233
+ height: z3.number().int().positive(),
234
+ devicePixelRatio: z3.number().finite().positive()
235
+ }),
236
+ scroll: z3.strictObject({ x: z3.number().finite(), y: z3.number().finite() }),
237
+ stabilityPending: z3.boolean(),
238
+ consoleInstrumentationActive: z3.boolean(),
239
+ consoleForwardingDropped: z3.number().int().nonnegative()
240
+ });
241
+ var pageStatusResultSchema = pageStatusContentSchema.extend({
242
+ tabId: z3.number().int().nonnegative(),
243
+ targetState: z3.enum(["authorized", "reauthorization_required", "none"]),
244
+ runtimeErrorCount: z3.number().int().nonnegative(),
245
+ consoleEntryCount: z3.number().int().nonnegative(),
246
+ networkFailureCount: z3.number().int().nonnegative()
247
+ });
248
+ var interactionTypeSchema = z3.enum([
249
+ "click",
250
+ "type",
251
+ "press_key",
252
+ "hover",
253
+ "scroll",
254
+ "select",
255
+ "focus"
256
+ ]);
257
+ var interactionTargetSchema = targetSchema.extend({
258
+ selector: z3.string().min(1).max(2048)
259
+ });
260
+ var typeSchema = interactionTargetSchema.extend({
261
+ text: z3.string().max(4096),
262
+ clear: z3.boolean().default(true)
263
+ });
264
+ var pressKeySchema = targetSchema.extend({
265
+ selector: z3.string().min(1).max(2048).optional(),
266
+ key: z3.string().max(12).refine(
267
+ (key) => key.length === 1 || [
268
+ "Enter",
269
+ "Escape",
270
+ "Tab",
271
+ "Backspace",
272
+ "Delete",
273
+ "ArrowUp",
274
+ "ArrowDown",
275
+ "ArrowLeft",
276
+ "ArrowRight",
277
+ "Home",
278
+ "End"
279
+ ].includes(key)
280
+ ),
281
+ shift: z3.boolean().default(false)
282
+ });
283
+ var scrollSchema = targetSchema.extend({
284
+ selector: z3.string().min(1).max(2048).optional(),
285
+ x: z3.number().finite().min(-1e6).max(1e6).default(0),
286
+ y: z3.number().finite().min(-1e6).max(1e6).default(0),
287
+ mode: z3.enum(["by", "to"]).default("by")
288
+ });
289
+ var selectSchema = interactionTargetSchema.extend({
290
+ value: z3.string().max(4096)
291
+ });
292
+ var interactionResultSchema = z3.strictObject({
293
+ tabId: z3.number().int().nonnegative(),
294
+ url: z3.url(),
295
+ action: interactionTypeSchema,
296
+ inputSource: z3.literal("dom"),
297
+ completed: z3.literal(true),
298
+ scroll: z3.strictObject({ x: z3.number().finite(), y: z3.number().finite() }).optional()
299
+ });
300
+ var captureSchema = targetSchema.extend({
301
+ url: z3.url().optional(),
302
+ format: z3.enum(["png", "jpeg"]).default("jpeg"),
303
+ quality: z3.number().int().min(0).max(100).default(85),
304
+ waitForStable: z3.boolean().default(false),
305
+ includeOverlay: z3.boolean().optional(),
306
+ stableWindowMs: z3.number().int().min(20).max(1e3).default(100),
307
+ maxWaitMs: z3.number().int().min(50).max(5e3).default(2e3)
308
+ });
309
+ var reloadSchema = targetSchema.extend({
310
+ bypassCache: z3.boolean().default(false)
311
+ });
312
+ var regionCaptureSchema = captureSchema.extend({
313
+ x: z3.number().finite().nonnegative(),
314
+ y: z3.number().finite().nonnegative(),
315
+ width: z3.number().finite().positive().max(32768),
316
+ height: z3.number().finite().positive().max(32768)
317
+ });
318
+ var elementCaptureSchema = captureSchema.extend({
319
+ selector: z3.string().min(1).max(2048),
320
+ padding: z3.number().finite().min(0).max(1e3).default(0)
321
+ });
322
+ var fullPageCaptureSchema = captureSchema.extend({
323
+ maxPixels: z3.number().int().min(1).max(32e6).default(16e6),
324
+ maxSegments: z3.number().int().min(1).max(64).default(32),
325
+ timeoutMs: z3.number().int().min(500).max(8e3).default(7e3)
326
+ });
327
+ var navigateSchema = targetSchema.extend({ url: z3.url() });
328
+ var diffSchema = z3.strictObject({
329
+ baseline: z3.uuid(),
330
+ current: z3.uuid().optional(),
331
+ threshold: z3.number().finite().min(0).max(1).default(0.1)
332
+ });
333
+ var viewportProfileSchema = z3.enum([
334
+ "mobile-small",
335
+ "mobile",
336
+ "tablet",
337
+ "desktop",
338
+ "desktop-wide"
339
+ ]);
340
+ var setViewportSchema = targetSchema.extend({
341
+ profile: viewportProfileSchema.optional(),
342
+ width: z3.number().int().min(200).max(8192).optional(),
343
+ height: z3.number().int().min(200).max(8192).optional(),
344
+ dedicatedPreview: z3.boolean().default(true)
345
+ });
346
+ var viewportResultSchema = z3.strictObject({
347
+ tabId: z3.number().int().nonnegative(),
348
+ url: z3.url(),
349
+ windowId: z3.number().int().nonnegative(),
350
+ width: z3.number().int().positive(),
351
+ height: z3.number().int().positive(),
352
+ devicePixelRatio: z3.number().finite().positive(),
353
+ zoom: z3.number().finite().positive(),
354
+ outerWidth: z3.number().int().positive(),
355
+ outerHeight: z3.number().int().positive(),
356
+ dedicatedPreview: z3.boolean(),
357
+ sourceTabId: z3.number().int().nonnegative().optional(),
358
+ requested: z3.strictObject({
359
+ width: z3.number().int().positive(),
360
+ height: z3.number().int().positive(),
361
+ profile: viewportProfileSchema.optional()
362
+ }).optional(),
363
+ exact: z3.boolean().optional(),
364
+ attempts: z3.number().int().min(0).max(3).optional(),
365
+ requiresTargetSelection: z3.boolean().optional(),
366
+ sourceWindowUnchanged: z3.boolean().optional()
367
+ });
368
+ var domSchema = targetSchema.extend({
369
+ selector: z3.string().min(1).max(2048).optional(),
370
+ depth: z3.number().int().min(0).max(8).default(3),
371
+ maxNodes: z3.number().int().min(1).max(300).default(100),
372
+ textLength: z3.number().int().min(0).max(300).default(160),
373
+ maxBytes: z3.number().int().min(2048).max(65536).default(32768)
374
+ });
375
+ var getElementSchema = domSchema.extend({
376
+ selector: z3.string().min(1).max(2048)
377
+ });
378
+ var changedRegionsSchema = targetSchema.extend({
379
+ maxRegions: z3.number().int().min(1).max(100).default(50),
380
+ reset: z3.boolean().default(true)
381
+ });
382
+ var capabilitySchema = z3.enum([
383
+ "highlight",
384
+ "clear_highlight",
385
+ "capture_region",
386
+ "capture_element",
387
+ "capture_full_page",
388
+ "get_dom",
389
+ "get_element",
390
+ "get_changed_regions",
391
+ "get_viewport",
392
+ "set_viewport",
393
+ ...interactionTypeSchema.options,
394
+ "get_console",
395
+ "get_network_failures",
396
+ "get_page_status"
397
+ ]);
398
+ var boundsSchema = z3.strictObject({
399
+ x: z3.number().finite(),
400
+ y: z3.number().finite(),
401
+ width: z3.number().finite().nonnegative(),
402
+ height: z3.number().finite().nonnegative()
403
+ });
404
+ var domNodeSchema = z3.strictObject({
405
+ index: z3.number().int().nonnegative(),
406
+ parent: z3.number().int().nonnegative().optional(),
407
+ depth: z3.number().int().min(0).max(8),
408
+ tag: z3.string().max(80),
409
+ id: z3.string().max(160).optional(),
410
+ classes: z3.array(z3.string().max(80)).max(6).optional(),
411
+ role: z3.string().max(80).optional(),
412
+ text: z3.string().max(300).optional(),
413
+ ariaLabel: z3.string().max(300).optional(),
414
+ name: z3.string().max(160).optional(),
415
+ type: z3.string().max(80).optional(),
416
+ href: z3.string().max(1024).optional(),
417
+ bounds: boundsSchema,
418
+ visible: z3.boolean(),
419
+ redacted: z3.boolean().optional(),
420
+ disabled: z3.boolean().optional(),
421
+ checked: z3.boolean().optional(),
422
+ selected: z3.boolean().optional()
423
+ });
424
+ var domResultSchema = z3.strictObject({
425
+ tabId: z3.number().int().nonnegative(),
426
+ url: z3.url(),
427
+ nodes: z3.array(domNodeSchema).max(300),
428
+ truncated: z3.boolean(),
429
+ payloadBytes: z3.number().int().max(65536)
430
+ });
431
+ var changedRegionsResultSchema = z3.strictObject({
432
+ tabId: z3.number().int().nonnegative(),
433
+ url: z3.url(),
434
+ regions: z3.array(
435
+ boundsSchema.extend({
436
+ reason: z3.enum(["mutation", "resize", "attribute", "text"]),
437
+ selector: z3.string().max(240).optional()
438
+ })
439
+ ).max(100),
440
+ trackingStarted: z3.boolean(),
441
+ dropped: z3.number().int().nonnegative(),
442
+ coverage: z3.literal("potential-dom-and-layout-changes")
443
+ });
444
+ var errorSchema = z3.strictObject({
445
+ type: z3.enum(["error", "unhandledrejection", "console-error"]),
446
+ message: z3.string().max(4096),
447
+ source: z3.string().max(2048).optional(),
448
+ line: z3.number().int().nonnegative().optional(),
449
+ column: z3.number().int().nonnegative().optional(),
450
+ timestamp: z3.number().nonnegative()
451
+ });
452
+ var stateSchema = z3.strictObject({
453
+ extensionVersion: z3.string().max(80).optional(),
454
+ extensionId: z3.string().min(1).max(256).optional(),
455
+ capabilities: z3.array(capabilitySchema).max(20).optional(),
456
+ selectedTab: z3.number().int().nonnegative().nullable(),
457
+ url: z3.string().max(2048).nullable(),
458
+ targetState: z3.enum(["none", "authorized", "reauthorization_required"]).optional(),
459
+ browser: z3.strictObject({
460
+ name: z3.enum(["firefox", "chrome"]),
461
+ version: z3.string().max(80)
462
+ }).optional(),
463
+ backgroundCapture: z3.strictObject({
464
+ enabled: z3.boolean(),
465
+ permissionGranted: z3.boolean()
466
+ }).optional()
467
+ });
468
+ var requestSchema = z3.discriminatedUnion("type", [
469
+ z3.strictObject({
470
+ id: z3.uuid(),
471
+ version: z3.literal(1),
472
+ type: z3.literal("highlight"),
473
+ payload: highlightSchema
474
+ }),
475
+ z3.strictObject({
476
+ id: z3.uuid(),
477
+ version: z3.literal(1),
478
+ type: z3.literal("clear_highlight"),
479
+ payload: targetSchema
480
+ }),
481
+ z3.strictObject({
482
+ id: z3.uuid(),
483
+ version: z3.literal(1),
484
+ type: z3.literal("get_console"),
485
+ payload: diagnosticsQuerySchema
486
+ }),
487
+ z3.strictObject({
488
+ id: z3.uuid(),
489
+ version: z3.literal(1),
490
+ type: z3.literal("get_network_failures"),
491
+ payload: diagnosticsQuerySchema
492
+ }),
493
+ z3.strictObject({
494
+ id: z3.uuid(),
495
+ version: z3.literal(1),
496
+ type: z3.literal("get_page_status"),
497
+ payload: targetSchema
498
+ }),
499
+ z3.strictObject({
500
+ id: z3.uuid(),
501
+ version: z3.literal(1),
502
+ type: z3.literal("click"),
503
+ payload: interactionTargetSchema
504
+ }),
505
+ z3.strictObject({
506
+ id: z3.uuid(),
507
+ version: z3.literal(1),
508
+ type: z3.literal("type"),
509
+ payload: typeSchema
510
+ }),
511
+ z3.strictObject({
512
+ id: z3.uuid(),
513
+ version: z3.literal(1),
514
+ type: z3.literal("press_key"),
515
+ payload: pressKeySchema
516
+ }),
517
+ z3.strictObject({
518
+ id: z3.uuid(),
519
+ version: z3.literal(1),
520
+ type: z3.literal("hover"),
521
+ payload: interactionTargetSchema
522
+ }),
523
+ z3.strictObject({
524
+ id: z3.uuid(),
525
+ version: z3.literal(1),
526
+ type: z3.literal("scroll"),
527
+ payload: scrollSchema
528
+ }),
529
+ z3.strictObject({
530
+ id: z3.uuid(),
531
+ version: z3.literal(1),
532
+ type: z3.literal("select"),
533
+ payload: selectSchema
534
+ }),
535
+ z3.strictObject({
536
+ id: z3.uuid(),
537
+ version: z3.literal(1),
538
+ type: z3.literal("focus"),
539
+ payload: interactionTargetSchema
540
+ }),
541
+ z3.strictObject({
542
+ id: z3.uuid(),
543
+ version: z3.literal(1),
544
+ type: z3.literal("get_viewport"),
545
+ payload: targetSchema
546
+ }),
547
+ z3.strictObject({
548
+ id: z3.uuid(),
549
+ version: z3.literal(1),
550
+ type: z3.literal("set_viewport"),
551
+ payload: setViewportSchema
552
+ }),
553
+ z3.strictObject({
554
+ id: z3.uuid(),
555
+ version: z3.literal(1),
556
+ type: z3.literal("get_dom"),
557
+ payload: domSchema
558
+ }),
559
+ z3.strictObject({
560
+ id: z3.uuid(),
561
+ version: z3.literal(1),
562
+ type: z3.literal("get_element"),
563
+ payload: getElementSchema
564
+ }),
565
+ z3.strictObject({
566
+ id: z3.uuid(),
567
+ version: z3.literal(1),
568
+ type: z3.literal("get_changed_regions"),
569
+ payload: changedRegionsSchema
570
+ }),
571
+ z3.strictObject({
572
+ id: z3.uuid(),
573
+ version: z3.literal(1),
574
+ type: z3.literal("capture_region"),
575
+ payload: regionCaptureSchema
576
+ }),
577
+ z3.strictObject({
578
+ id: z3.uuid(),
579
+ version: z3.literal(1),
580
+ type: z3.literal("capture_element"),
581
+ payload: elementCaptureSchema
582
+ }),
583
+ z3.strictObject({
584
+ id: z3.uuid(),
585
+ version: z3.literal(1),
586
+ type: z3.literal("capture_full_page"),
587
+ payload: fullPageCaptureSchema
588
+ }),
589
+ z3.strictObject({
590
+ id: z3.uuid(),
591
+ version: z3.literal(1),
592
+ type: z3.literal("capture"),
593
+ payload: captureSchema
594
+ }),
595
+ z3.strictObject({
596
+ id: z3.uuid(),
597
+ version: z3.literal(1),
598
+ type: z3.literal("reload"),
599
+ payload: reloadSchema
600
+ }),
601
+ z3.strictObject({
602
+ id: z3.uuid(),
603
+ version: z3.literal(1),
604
+ type: z3.literal("navigate"),
605
+ payload: navigateSchema
606
+ }),
607
+ z3.strictObject({
608
+ id: z3.uuid(),
609
+ version: z3.literal(1),
610
+ type: z3.literal("get_errors"),
611
+ payload: targetSchema
612
+ })
613
+ ]);
614
+ var responseSchema = z3.discriminatedUnion("ok", [
615
+ z3.strictObject({
616
+ id: z3.uuid(),
617
+ version: z3.literal(1),
618
+ ok: z3.literal(true),
619
+ result: z3.unknown()
620
+ }),
621
+ z3.strictObject({
622
+ id: z3.uuid(),
623
+ version: z3.literal(1),
624
+ ok: z3.literal(false),
625
+ error: z3.strictObject({
626
+ code: z3.string().max(80),
627
+ message: z3.string().max(4096),
628
+ stability: stabilityDiagnosticsSchema.optional()
629
+ })
630
+ })
631
+ ]);
632
+ var helloSchema = z3.strictObject({
633
+ version: z3.literal(1),
634
+ type: z3.literal("hello"),
635
+ token: z3.string().regex(/^[a-f0-9]{64}$/),
636
+ state: stateSchema
637
+ });
638
+ var stateMessageSchema = z3.strictObject({
639
+ version: z3.literal(1),
640
+ type: z3.literal("state"),
641
+ state: stateSchema
642
+ });
643
+ var captureResultSchema = z3.strictObject({
644
+ tabId: z3.number().int().nonnegative(),
645
+ url: z3.url(),
646
+ width: z3.number().int().positive(),
647
+ height: z3.number().int().positive(),
648
+ format: z3.enum(["png", "jpeg"]),
649
+ captureMs: z3.number().nonnegative(),
650
+ processingMs: z3.number().nonnegative().optional(),
651
+ totalMs: z3.number().nonnegative().optional(),
652
+ segments: z3.number().int().positive().max(64).optional(),
653
+ scrollRestored: z3.boolean().optional(),
654
+ stabilityWaitMs: z3.number().nonnegative().optional(),
655
+ extensionTotalMs: z3.number().nonnegative().optional(),
656
+ renderAppearsStable: z3.boolean().optional(),
657
+ stable: z3.boolean().optional(),
658
+ lastMutationAgoMs: z3.number().finite().nonnegative().optional(),
659
+ fontsReady: z3.boolean().optional(),
660
+ pendingImages: z3.number().int().nonnegative().optional(),
661
+ timeoutReached: z3.boolean().optional(),
662
+ dataUrl: z3.string().max(16 * 1024 * 1024)
663
+ });
664
+ var tabResultSchema = z3.strictObject({
665
+ tabId: z3.number().int().nonnegative(),
666
+ url: z3.url()
667
+ });
668
+ var errorsResultSchema = z3.strictObject({
669
+ tabId: z3.number().int().nonnegative(),
670
+ errors: z3.array(errorSchema).max(200)
671
+ });
672
+ var GSError = class extends Error {
673
+ constructor(code, message, stability) {
674
+ super(message);
675
+ this.code = code;
676
+ this.stability = stability;
677
+ }
678
+ };
679
+
680
+ // apps/mcp-server/src/bridge/pairing.ts
681
+ import { timingSafeEqual } from "node:crypto";
682
+ function tokensMatch(actual, expected) {
683
+ const a = Buffer.from(actual);
684
+ const b = Buffer.from(expected);
685
+ return a.length === b.length && timingSafeEqual(a, b);
686
+ }
687
+ function originAllowed(origin) {
688
+ return origin === void 0 || /^moz-extension:\/\/[a-f0-9-]+\/?$/i.test(origin) || /^chrome-extension:\/\/[a-p]{32}\/?$/.test(origin);
689
+ }
690
+
691
+ // apps/mcp-server/src/bridge/request-router.ts
692
+ import { randomUUID } from "node:crypto";
693
+ var RequestRouter = class {
694
+ constructor(send, timeoutMs = 1e4) {
695
+ this.send = send;
696
+ this.timeoutMs = timeoutMs;
697
+ }
698
+ pending = /* @__PURE__ */ new Map();
699
+ request(type, payload) {
700
+ const request = requestSchema.parse({
701
+ id: randomUUID(),
702
+ version: 1,
703
+ type,
704
+ payload
705
+ });
706
+ return new Promise((resolve2, reject) => {
707
+ const timer = setTimeout(() => {
708
+ this.pending.delete(request.id);
709
+ reject(
710
+ new GSError(
711
+ "GS_TIMEOUT",
712
+ "Firefox did not respond in time. Check the extension connection."
713
+ )
714
+ );
715
+ }, this.timeoutMs);
716
+ this.pending.set(request.id, { resolve: resolve2, reject, timer });
717
+ try {
718
+ this.send(request);
719
+ } catch (error) {
720
+ clearTimeout(timer);
721
+ this.pending.delete(request.id);
722
+ reject(error);
723
+ }
724
+ });
725
+ }
726
+ receive(value) {
727
+ const response = responseSchema.parse(value);
728
+ const call = this.pending.get(response.id);
729
+ if (!call)
730
+ throw new GSError(
731
+ "GS_INVALID_MESSAGE",
732
+ "Unknown or duplicate response ID."
733
+ );
734
+ clearTimeout(call.timer);
735
+ this.pending.delete(response.id);
736
+ if (response.ok) call.resolve(response.result);
737
+ else
738
+ call.reject(
739
+ new GSError(
740
+ response.error.code,
741
+ response.error.message,
742
+ response.error.stability
743
+ )
744
+ );
745
+ }
746
+ disconnect() {
747
+ for (const call of this.pending.values()) {
748
+ clearTimeout(call.timer);
749
+ call.reject(
750
+ new GSError(
751
+ "GS_NOT_CONNECTED",
752
+ "Firefox disconnected. Wait for reconnection."
753
+ )
754
+ );
755
+ }
756
+ this.pending.clear();
757
+ }
758
+ };
759
+
760
+ // apps/mcp-server/src/bridge/connection-registry.ts
761
+ function originOf(url) {
762
+ if (url === null) return null;
763
+ try {
764
+ return new URL(url).origin;
765
+ } catch {
766
+ return null;
767
+ }
768
+ }
769
+ var BrowserConnectionRegistry = class {
770
+ connections = /* @__PURE__ */ new Map();
771
+ nextGeneration = 1;
772
+ nextSuffix = 1;
773
+ get(browser) {
774
+ return this.connections.get(browser);
775
+ }
776
+ byConnectionId(connectionId) {
777
+ for (const connection of this.connections.values())
778
+ if (connection.connectionId === connectionId) return connection;
779
+ return void 0;
780
+ }
781
+ all() {
782
+ return [...this.connections.values()];
783
+ }
784
+ isDuplicate(browser) {
785
+ return this.connections.has(browser);
786
+ }
787
+ // Returns the new connection, or undefined if `browser` already has a live
788
+ // connection (duplicate — caller must refuse the new socket, not replace).
789
+ register(browser, initialState, now = Date.now()) {
790
+ if (this.connections.has(browser)) return void 0;
791
+ const connection = {
792
+ connectionId: `${browser}-${this.nextSuffix++}`,
793
+ browser,
794
+ lifecycleGeneration: this.nextGeneration++,
795
+ connectedAt: now,
796
+ state: initialState,
797
+ lastSeenAt: now
798
+ };
799
+ this.connections.set(browser, connection);
800
+ return connection;
801
+ }
802
+ // Removes a connection by id, only if it is still the one currently
803
+ // registered for its browser family (guards against a stale remove for a
804
+ // connection that was already superseded).
805
+ remove(connectionId) {
806
+ for (const [browser, connection] of this.connections)
807
+ if (connection.connectionId === connectionId) {
808
+ this.connections.delete(browser);
809
+ return connection;
810
+ }
811
+ return void 0;
812
+ }
813
+ updateState(connectionId, state, now = Date.now()) {
814
+ const connection = this.byConnectionId(connectionId);
815
+ if (!connection) return void 0;
816
+ const previousTargetState = connection.state.targetState;
817
+ connection.state = state;
818
+ connection.lastSeenAt = now;
819
+ return { connection, previousTargetState };
820
+ }
821
+ };
822
+ function isExplicitActivation(nextTargetState, isCurrentlyActive) {
823
+ return nextTargetState === "authorized" && !isCurrentlyActive;
824
+ }
825
+ var ActiveTargetRouter = class {
826
+ route;
827
+ sequence = 0;
828
+ get current() {
829
+ return this.route;
830
+ }
831
+ activate(connection) {
832
+ this.route = {
833
+ connectionId: connection.connectionId,
834
+ browser: connection.browser,
835
+ activationSequence: ++this.sequence,
836
+ activatedAt: Date.now(),
837
+ tabId: connection.state.selectedTab,
838
+ origin: originOf(connection.state.url)
839
+ };
840
+ return this.route;
841
+ }
842
+ considerStateUpdate(connection) {
843
+ const isCurrentlyActive = this.route?.connectionId === connection.connectionId;
844
+ if (!isExplicitActivation(connection.state.targetState, isCurrentlyActive))
845
+ return this.route;
846
+ return this.activate(connection);
847
+ }
848
+ // Reconnect-only restoration (GAZESIGHT_MASTER_SPEC.md §142/§143): a
849
+ // newly-registered connection may rebind the EXISTING active route only if
850
+ // it is provably a resumption of the exact same logical session that
851
+ // already owned it — never a new activation path. This is deliberately
852
+ // NOT the same operation as considerStateUpdate: a hello can never create a
853
+ // route from nothing here (requirement 1), and it can never take a route
854
+ // away from a different browser family that has since become active
855
+ // (requirement 2 — see below), including after an MCP restart, where
856
+ // `this.route` starts undefined and requirement 1 alone already blocks it.
857
+ //
858
+ // Every one of the following must hold, in order, fail-closed on the first
859
+ // mismatch (no partial/fuzzy match — restoration is authoritative identity
860
+ // matching, never time-based trust):
861
+ // 1. A route already exists — hello alone never creates one.
862
+ // 2. The route belongs to the SAME browser family as this connection —
863
+ // if a different browser was explicitly activated in the meantime
864
+ // (including while this one was disconnected), that activation always
865
+ // wins and is never reclaimed.
866
+ // 3. The reconnecting hello reports the target as already authorized
867
+ // (the browser's own extension, e.g. Continuous Access, already
868
+ // independently re-validated the tab/origin/allow-list before ever
869
+ // setting this).
870
+ // 4. The reconnecting tab id matches the tab id recorded at the route's
871
+ // last activation — never trusts "same origin, different tab".
872
+ // 5. The reconnecting origin matches the origin recorded at the route's
873
+ // last activation, exactly (no aliasing) — an unresolvable origin on
874
+ // either side is treated as incompatible, not as a pass.
875
+ // A stale connectionId from before a teardown can never reach this
876
+ // function at all (the registry only looks up currently-registered
877
+ // connections by id — see BrowserConnectionRegistry.byConnectionId/
878
+ // updateState), so a delayed message from an already-torn-down socket
879
+ // cannot reclaim anything.
880
+ considerReconnectRestore(connection) {
881
+ if (!this.route) return void 0;
882
+ if (this.route.connectionId === connection.connectionId) return this.route;
883
+ if (this.route.browser !== connection.browser) return this.route;
884
+ if (connection.state.targetState !== "authorized") return this.route;
885
+ if (this.route.tabId === null || this.route.tabId !== connection.state.selectedTab)
886
+ return this.route;
887
+ const origin = originOf(connection.state.url);
888
+ if (this.route.origin === null || origin === null || this.route.origin !== origin)
889
+ return this.route;
890
+ return this.activate(connection);
891
+ }
892
+ };
893
+
894
+ // apps/mcp-server/src/bridge/websocket-server.ts
895
+ import { createServer } from "node:http";
896
+
897
+ // apps/mcp-server/src/bridge/pairing-service.ts
898
+ import { createHash, randomBytes as randomBytes2, randomInt, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
899
+ var hash = (value) => createHash("sha256").update(value).digest("hex");
900
+ var equalHash = (value, expected) => {
901
+ const actual = Buffer.from(hash(value));
902
+ const wanted = Buffer.from(expected);
903
+ return actual.length === wanted.length && timingSafeEqual2(actual, wanted);
904
+ };
905
+ var json = (response, status, value) => {
906
+ response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
907
+ response.end(JSON.stringify(value));
908
+ };
909
+ async function body(request) {
910
+ const chunks = [];
911
+ let length = 0;
912
+ for await (const chunk of request) {
913
+ length += chunk.length;
914
+ if (length > 4096) throw new Error("body too large");
915
+ chunks.push(chunk);
916
+ }
917
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
918
+ }
919
+ function browserForOrigin(origin) {
920
+ if (/^moz-extension:\/\/[a-f0-9-]+\/?$/i.test(origin)) return "firefox";
921
+ if (/^chrome-extension:\/\/[a-p]{32}\/?$/.test(origin)) return "chrome";
922
+ }
923
+ var PairingService = class {
924
+ constructor(token, configPath, initial) {
925
+ this.token = token;
926
+ this.configPath = configPath;
927
+ this.pairings = initial;
928
+ }
929
+ pending = /* @__PURE__ */ new Map();
930
+ recent = /* @__PURE__ */ new Map();
931
+ pairings;
932
+ authenticate(credential, origin, browser) {
933
+ if (!origin) return false;
934
+ return this.pairings.some((pairing) => !pairing.revokedAt && pairing.browser === browser && pairing.origin === origin && equalHash(credential, pairing.credentialHash));
935
+ }
936
+ listPending() {
937
+ this.expire();
938
+ return [...this.pending.values()].filter((item) => item.state === "pending").map(({ id, browser, extensionId, code, createdAt, expiresAt }) => ({ id, browser, extensionId, code, createdAt, expiresAt }));
939
+ }
940
+ listPairings() {
941
+ return this.pairings.map(({ id, browser, extensionId, origin, createdAt, revokedAt }) => ({ id, browser, extensionId, origin, createdAt, ...revokedAt ? { revokedAt } : {} }));
942
+ }
943
+ deliveryState(id) {
944
+ const item = this.pending.get(id);
945
+ return item ? { state: item.state, redeemed: item.redeemed === true, expiresAt: item.expiresAt } : void 0;
946
+ }
947
+ approve(id, code) {
948
+ this.expire();
949
+ const item = this.pending.get(id);
950
+ if (!item || item.state !== "pending" || item.code !== code) return false;
951
+ const credential = randomBytes2(32).toString("hex");
952
+ const pairing = { id: crypto.randomUUID(), browser: item.browser, extensionId: item.extensionId, origin: item.origin, credentialHash: hash(credential), createdAt: (/* @__PURE__ */ new Date()).toISOString() };
953
+ this.pairings.push(pairing);
954
+ item.credential = credential;
955
+ item.state = "approved";
956
+ this.persist();
957
+ return true;
958
+ }
959
+ reject(id) {
960
+ const item = this.pending.get(id);
961
+ if (!item || item.state !== "pending") return false;
962
+ item.state = "rejected";
963
+ return true;
964
+ }
965
+ revoke(id) {
966
+ const item = this.pairings.find((pairing) => pairing.id === id && !pairing.revokedAt);
967
+ if (!item) return false;
968
+ item.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
969
+ this.persist();
970
+ return true;
971
+ }
972
+ persist() {
973
+ if (!this.configPath) return;
974
+ const config = readLocalConfig(this.configPath);
975
+ writeLocalConfig(this.configPath, { ...config, pairings: this.pairings });
976
+ }
977
+ expire() {
978
+ const now = Date.now();
979
+ for (const item of this.pending.values()) if (item.expiresAt <= now && item.state === "pending") item.state = "rejected";
980
+ }
981
+ admin(request) {
982
+ return request.headers.origin === void 0 && tokensMatch(request.headers.authorization?.replace(/^Bearer /, "") ?? "", this.token);
983
+ }
984
+ async handle(request, response) {
985
+ const url = request.url;
986
+ if (!url?.startsWith("/gazesight/pairing/")) return false;
987
+ const origin = request.headers.origin;
988
+ if (origin && originAllowed(origin)) response.setHeader("Access-Control-Allow-Origin", origin);
989
+ response.setHeader("Vary", "Origin");
990
+ if (request.method === "OPTIONS" && origin && originAllowed(origin)) {
991
+ response.writeHead(204, { "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "content-type, authorization" }).end();
992
+ return true;
993
+ }
994
+ try {
995
+ if (url === "/gazesight/pairing/request" && request.method === "POST") {
996
+ const browser = origin ? browserForOrigin(origin) : void 0;
997
+ if (!origin || !browser) {
998
+ json(response, 403, { error: "ineligible_origin" });
999
+ return true;
1000
+ }
1001
+ const now = Date.now();
1002
+ const attempts = (this.recent.get(origin) ?? []).filter((time) => now - time < 6e4);
1003
+ if (attempts.length >= 5) {
1004
+ json(response, 429, { error: "rate_limited" });
1005
+ return true;
1006
+ }
1007
+ attempts.push(now);
1008
+ this.recent.set(origin, attempts);
1009
+ const value = await body(request);
1010
+ if (value.browser !== browser || typeof value.extensionId !== "string" || value.extensionId.length > 256 || typeof value.secret !== "string" || !/^[a-f0-9]{64}$/.test(value.secret)) {
1011
+ json(response, 400, { error: "invalid_request" });
1012
+ return true;
1013
+ }
1014
+ const item = { id: crypto.randomUUID(), browser, extensionId: value.extensionId, origin, secretHash: hash(value.secret), code: String(randomInt(0, 1e6)).padStart(6, "0"), createdAt: now, expiresAt: now + 12e4, attempts: 0, state: "pending" };
1015
+ this.pending.set(item.id, item);
1016
+ json(response, 201, { requestId: item.id, code: item.code, expiresAt: item.expiresAt });
1017
+ return true;
1018
+ }
1019
+ if (url === "/gazesight/pairing/status" && request.method === "POST") {
1020
+ const value = await body(request);
1021
+ const item = typeof value.requestId === "string" ? this.pending.get(value.requestId) : void 0;
1022
+ if (!item || item.origin !== origin || typeof value.secret !== "string") {
1023
+ json(response, 404, { state: "unavailable" });
1024
+ return true;
1025
+ }
1026
+ item.attempts += 1;
1027
+ if (item.attempts > 180 || !equalHash(value.secret, item.secretHash)) {
1028
+ json(response, 403, { state: "rejected" });
1029
+ return true;
1030
+ }
1031
+ if (Date.now() >= item.expiresAt) {
1032
+ json(response, 410, { state: "expired" });
1033
+ return true;
1034
+ }
1035
+ if (item.state === "approved" && item.credential && !item.redeemed) {
1036
+ const pairing = this.pairings.find((value2) => value2.origin === item.origin && value2.extensionId === item.extensionId && value2.credentialHash === hash(item.credential));
1037
+ item.redeemed = true;
1038
+ json(response, 200, { state: "approved", credential: item.credential, pairingId: pairing?.id });
1039
+ item.credential = void 0;
1040
+ return true;
1041
+ }
1042
+ json(response, 200, { state: item.state });
1043
+ return true;
1044
+ }
1045
+ if (url === "/gazesight/pairing/revoke-self" && request.method === "POST" && origin) {
1046
+ const value = await body(request);
1047
+ const id = typeof value.id === "string" ? value.id : "";
1048
+ const credential = request.headers.authorization?.replace(/^Bearer /, "") ?? "";
1049
+ const pairing = this.pairings.find((item) => item.id === id && item.origin === origin && !item.revokedAt && equalHash(credential, item.credentialHash));
1050
+ if (!pairing) {
1051
+ json(response, 403, { ok: false });
1052
+ return true;
1053
+ }
1054
+ pairing.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
1055
+ this.persist();
1056
+ json(response, 200, { ok: true });
1057
+ return true;
1058
+ }
1059
+ if (!this.admin(request)) {
1060
+ json(response, 404, { error: "not_found" });
1061
+ return true;
1062
+ }
1063
+ if (url === "/gazesight/pairing/delivery" && request.method === "POST") {
1064
+ const value = await body(request);
1065
+ const delivery = typeof value.id === "string" ? this.deliveryState(value.id) : void 0;
1066
+ json(response, delivery ? 200 : 404, delivery ?? { error: "not_found" });
1067
+ return true;
1068
+ }
1069
+ if (url === "/gazesight/pairing/pending" && request.method === "GET") {
1070
+ json(response, 200, { requests: this.listPending() });
1071
+ return true;
1072
+ }
1073
+ if (url === "/gazesight/pairing/list" && request.method === "GET") {
1074
+ json(response, 200, { pairings: this.listPairings() });
1075
+ return true;
1076
+ }
1077
+ if (request.method === "POST" && ["approve", "reject", "revoke"].some((action) => url === `/gazesight/pairing/${action}`)) {
1078
+ const value = await body(request);
1079
+ const id = typeof value.id === "string" ? value.id : "";
1080
+ const ok = url.endsWith("approve") ? this.approve(id, typeof value.code === "string" ? value.code : "") : url.endsWith("reject") ? this.reject(id) : this.revoke(id);
1081
+ json(response, ok ? 200 : 404, { ok });
1082
+ return true;
1083
+ }
1084
+ json(response, 404, { error: "not_found" });
1085
+ return true;
1086
+ } catch {
1087
+ json(response, 400, { error: "invalid_request" });
1088
+ return true;
1089
+ }
1090
+ }
1091
+ };
1092
+
1093
+ // apps/mcp-server/src/bridge/websocket-server.ts
1094
+ var EMPTY_STATE = { selectedTab: null, url: null };
1095
+ function browserFromOrigin(origin) {
1096
+ if (origin === void 0) return void 0;
1097
+ if (/^moz-extension:\/\/[a-f0-9-]+\/?$/i.test(origin)) return "firefox";
1098
+ if (/^chrome-extension:\/\/[a-p]{32}\/?$/.test(origin)) return "chrome";
1099
+ return void 0;
1100
+ }
1101
+ var Bridge = class {
1102
+ constructor(config) {
1103
+ this.config = config;
1104
+ this.pairing = new PairingService(config.token, config.configPath, config.pairings ?? []);
1105
+ const verifyClient = (info) => originAllowed(info.origin || void 0);
1106
+ this.http = createServer(async (request, response) => {
1107
+ response.setHeader("Cache-Control", "no-store");
1108
+ response.setHeader("X-GazeSight-Version", VERSION);
1109
+ if (await this.pairing.handle(request, response)) return;
1110
+ if (request.method !== "GET" || request.url !== "/gazesight/status" || request.headers.origin !== void 0) {
1111
+ response.writeHead(404).end();
1112
+ return;
1113
+ }
1114
+ if (!tokensMatch(
1115
+ request.headers.authorization?.replace(/^Bearer /, "") ?? "",
1116
+ config.token
1117
+ )) {
1118
+ response.writeHead(401).end();
1119
+ return;
1120
+ }
1121
+ response.setHeader("Content-Type", "application/json");
1122
+ response.end(JSON.stringify(this.status()));
1123
+ });
1124
+ this.server = new WebSocketServer({
1125
+ server: this.http,
1126
+ maxPayload: 17 * 1024 * 1024,
1127
+ perMessageDeflate: false,
1128
+ verifyClient
1129
+ });
1130
+ this.http.listen(config.port, "127.0.0.1");
1131
+ this.server.on("connection", (socket, request) => {
1132
+ let paired = false;
1133
+ let connection;
1134
+ const originBrowser = browserFromOrigin(request.headers.origin);
1135
+ const timer = setTimeout(
1136
+ () => socket.close(1008, "Pairing required"),
1137
+ 3e3
1138
+ );
1139
+ const teardown = () => {
1140
+ clearTimeout(timer);
1141
+ if (!connection) return;
1142
+ this.wiring.get(connection.connectionId)?.router.disconnect();
1143
+ this.wiring.delete(connection.connectionId);
1144
+ this.registry.remove(connection.connectionId);
1145
+ };
1146
+ socket.on("error", () => {
1147
+ });
1148
+ socket.on("message", (bytes, binary) => {
1149
+ try {
1150
+ if (!paired && Buffer.byteLength(bytes.toString()) > 4096)
1151
+ throw new Error("Pairing message too large");
1152
+ if (binary) throw new Error("Binary messages forbidden");
1153
+ const message = JSON.parse(bytes.toString());
1154
+ if (!paired) {
1155
+ const hello = helloSchema.parse(message);
1156
+ const claimedBrowser = hello.state.browser?.name;
1157
+ if (originBrowser !== void 0 && claimedBrowser !== void 0 && originBrowser !== claimedBrowser)
1158
+ throw new Error("Origin/browser mismatch");
1159
+ const browser = claimedBrowser ?? originBrowser;
1160
+ if (browser === void 0)
1161
+ throw new Error("Unidentifiable browser");
1162
+ if (!tokensMatch(hello.token, config.token) && !this.pairing.authenticate(hello.token, request.headers.origin, browser))
1163
+ throw new Error("Pairing rejected");
1164
+ if (this.registry.isDuplicate(browser))
1165
+ throw new Error("Duplicate connection");
1166
+ connection = this.registry.register(browser, hello.state);
1167
+ if (!connection) throw new Error("Duplicate connection");
1168
+ const router = new RequestRouter((req) => {
1169
+ if (socket.readyState !== WebSocket.OPEN)
1170
+ throw new GSError(
1171
+ "GS_NOT_CONNECTED",
1172
+ "Start Firefox and pair the GazeSight extension in its settings."
1173
+ );
1174
+ socket.send(JSON.stringify(req));
1175
+ }, config.timeoutMs);
1176
+ this.wiring.set(connection.connectionId, { socket, router });
1177
+ this.activeTargetRouter.considerReconnectRestore(connection);
1178
+ paired = true;
1179
+ clearTimeout(timer);
1180
+ socket.send(JSON.stringify({ version: 1, type: "paired" }));
1181
+ return;
1182
+ }
1183
+ if (!connection) return;
1184
+ const state = stateMessageSchema.safeParse(message);
1185
+ if (state.success) {
1186
+ const result = this.registry.updateState(
1187
+ connection.connectionId,
1188
+ state.data.state
1189
+ );
1190
+ if (result) this.activeTargetRouter.considerStateUpdate(result.connection);
1191
+ return;
1192
+ }
1193
+ this.wiring.get(connection.connectionId)?.router.receive(message);
1194
+ } catch {
1195
+ socket.close(1008, "GS_INVALID_MESSAGE");
1196
+ }
1197
+ });
1198
+ socket.on("close", teardown);
1199
+ });
1200
+ }
1201
+ registry = new BrowserConnectionRegistry();
1202
+ activeTargetRouter = new ActiveTargetRouter();
1203
+ wiring = /* @__PURE__ */ new Map();
1204
+ server;
1205
+ http;
1206
+ pairing;
1207
+ // Resolves the connection currently backing the global active target, or
1208
+ // undefined if no browser is active (nothing selected yet, or the
1209
+ // previously active browser disconnected — no automatic fallback).
1210
+ resolveActive() {
1211
+ const route = this.activeTargetRouter.current;
1212
+ if (!route) return void 0;
1213
+ return this.registry.byConnectionId(route.connectionId);
1214
+ }
1215
+ resolveActiveWiring() {
1216
+ const route = this.activeTargetRouter.current;
1217
+ if (!route) return void 0;
1218
+ return this.wiring.get(route.connectionId);
1219
+ }
1220
+ get connected() {
1221
+ const wiring = this.resolveActiveWiring();
1222
+ return wiring?.socket.readyState === WebSocket.OPEN;
1223
+ }
1224
+ get state() {
1225
+ return this.resolveActive()?.state ?? EMPTY_STATE;
1226
+ }
1227
+ get port() {
1228
+ return this.config.port;
1229
+ }
1230
+ // Same call shape as before (`bridge.router.request(type, payload)`) so
1231
+ // server.ts and its 25 tools need no changes at all — routing to whichever
1232
+ // browser is active is entirely internal to Bridge.
1233
+ router = {
1234
+ request: async (type, payload) => {
1235
+ const route = this.activeTargetRouter.current;
1236
+ if (!route)
1237
+ throw new GSError(
1238
+ "GS_NOT_CONNECTED",
1239
+ "Start Firefox and pair the GazeSight extension in its settings."
1240
+ );
1241
+ const wiring = this.wiring.get(route.connectionId);
1242
+ if (!wiring || wiring.socket.readyState !== WebSocket.OPEN)
1243
+ throw new GSError(
1244
+ "GS_TARGET_UNAVAILABLE",
1245
+ "The active browser disconnected. Select the target again in that browser, or switch to another browser with its own 'Use this tab'."
1246
+ );
1247
+ return wiring.router.request(type, payload);
1248
+ }
1249
+ };
1250
+ status() {
1251
+ const active = this.resolveActive();
1252
+ const connectedBrowsers = Object.fromEntries(
1253
+ ["firefox", "chrome"].map((browser) => [
1254
+ browser,
1255
+ this.registry.get(browser) !== void 0 && this.wiring.get(this.registry.get(browser).connectionId)?.socket.readyState === WebSocket.OPEN
1256
+ ])
1257
+ );
1258
+ const authorizedTargets = Object.fromEntries(
1259
+ ["firefox", "chrome"].map((browser) => {
1260
+ const connection = this.registry.get(browser);
1261
+ return [
1262
+ browser,
1263
+ connection?.state.targetState === "authorized" ? { url: connection.state.url } : null
1264
+ ];
1265
+ })
1266
+ );
1267
+ return {
1268
+ connected: this.connected,
1269
+ paired: this.connected,
1270
+ authorizedPairings: this.pairing.listPairings().filter((item) => !item.revokedAt).length,
1271
+ targetState: this.state.targetState ?? "none",
1272
+ tabId: this.state.selectedTab,
1273
+ bridge: { port: this.port },
1274
+ ...this.state,
1275
+ connectedBrowsers,
1276
+ authorizedTargets,
1277
+ activeTarget: active ? { browser: active.browser, url: active.state.url } : null
1278
+ };
1279
+ }
1280
+ async ready() {
1281
+ if (this.server.address()) return;
1282
+ await new Promise((resolve2, reject) => {
1283
+ this.server.once("listening", resolve2);
1284
+ this.server.once("error", reject);
1285
+ });
1286
+ }
1287
+ async close() {
1288
+ for (const wiring of this.wiring.values()) wiring.router.disconnect();
1289
+ for (const client of this.server.clients) client.terminate();
1290
+ await new Promise((resolve2) => this.server.close(() => resolve2()));
1291
+ await new Promise((resolve2) => this.http.close(() => resolve2()));
1292
+ }
1293
+ };
1294
+
1295
+ // apps/cli/src/pair.ts
1296
+ var headers = (token) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
1297
+ async function admin(port, token, path, method = "GET", value) {
1298
+ return fetch(`http://127.0.0.1:${port}/gazesight/pairing/${path}`, {
1299
+ method,
1300
+ headers: headers(token),
1301
+ body: value === void 0 ? void 0 : JSON.stringify(value),
1302
+ signal: AbortSignal.timeout(1500)
1303
+ });
1304
+ }
1305
+ async function pairingFlow(config, configPath, revoke) {
1306
+ let bridge;
1307
+ try {
1308
+ const check = await admin(config.port, config.token, "list").catch(() => void 0);
1309
+ if (!check?.ok) {
1310
+ bridge = new Bridge({ ...config, configPath, timeoutMs: 1e4 });
1311
+ await bridge.ready();
1312
+ }
1313
+ if (revoke) {
1314
+ const current = await admin(config.port, config.token, "list");
1315
+ if (!current.ok) throw new Error("Pairing administration unavailable.");
1316
+ const pairings = (await current.json()).pairings;
1317
+ const ids = revoke === "all" ? pairings.filter((item) => !item.revokedAt).map((item) => item.id) : [revoke];
1318
+ let count = 0;
1319
+ for (const id of ids) if ((await admin(config.port, config.token, "revoke", "POST", { id })).ok) count++;
1320
+ console.log(`Revoked pairings: ${count}. Extensions must pair again.`);
1321
+ return;
1322
+ }
1323
+ if (!process.stdin.isTTY) {
1324
+ console.log("Pairing listener ready. Open the GazeSight extension and click Connect to GazeSight.");
1325
+ return;
1326
+ }
1327
+ console.log("Pairing listener ready. Open the GazeSight browser extension and click Connect to GazeSight.");
1328
+ console.log("Waiting for a request (Ctrl+C to stop)\u2026");
1329
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1330
+ try {
1331
+ for (; ; ) {
1332
+ const response = await admin(config.port, config.token, "pending");
1333
+ if (!response.ok) throw new Error("Pairing administration unavailable.");
1334
+ const requests = (await response.json()).requests;
1335
+ const request = requests[0];
1336
+ if (!request) {
1337
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
1338
+ continue;
1339
+ }
1340
+ console.log(`Pairing request: ${request.browser} \xB7 verification code ${request.code}`);
1341
+ const answer = (await rl.question("Approve this matching request? [y/N] ")).trim().toLowerCase();
1342
+ const approved = answer === "y" || answer === "yes";
1343
+ const result = await admin(config.port, config.token, approved ? "approve" : "reject", "POST", { id: request.id, code: request.code });
1344
+ if (!result.ok) throw new Error("The pairing request expired or was already used.");
1345
+ if (approved) {
1346
+ const deadline = Date.now() + 5e3;
1347
+ let delivered = false;
1348
+ while (Date.now() < deadline) {
1349
+ const delivery = await admin(config.port, config.token, "delivery", "POST", { id: request.id });
1350
+ if (delivery.ok && (await delivery.json()).redeemed) {
1351
+ delivered = true;
1352
+ break;
1353
+ }
1354
+ await new Promise((resolve2) => setTimeout(resolve2, 100));
1355
+ }
1356
+ if (!delivered) throw new Error("The approved credential was not delivered; keep the extension open and retry pairing.");
1357
+ console.log("Pairing approved and delivered. The extension can now reconnect securely.");
1358
+ } else console.log("Pairing rejected.");
1359
+ return;
1360
+ }
1361
+ } finally {
1362
+ rl.close();
1363
+ }
1364
+ } finally {
1365
+ if (bridge) await bridge.close();
1366
+ }
1367
+ }
1368
+
151
1369
  // apps/cli/src/index.ts
152
1370
  var root = resolve(dirname2(fileURLToPath(import.meta.url)), "..");
153
1371
  var args = process.argv.slice(2);
@@ -168,7 +1386,7 @@ function detectOnPath(binary) {
168
1386
  }
169
1387
  async function promptClientChoice() {
170
1388
  if (!process.stdin.isTTY) return ["claude", "codex"];
171
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1389
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
172
1390
  try {
173
1391
  const answer = (await rl.question(
174
1392
  "Configure MCP for:\n 1) Claude Code\n 2) Codex\n 3) Both\nEnter choice [1-3] (default 3): "
@@ -222,15 +1440,16 @@ try {
222
1440
  console.log(
223
1441
  `GazeSight ${VERSION}
224
1442
  gazesight setup [--config PATH] [--port PORT] [--client claude|codex|both]
1443
+ gazesight pair [--config PATH] [--revoke ID|all]
225
1444
  gazesight doctor [--config PATH]
226
1445
  gazesight mcp [--config PATH]
227
1446
  No browser is launched. Secrets are never printed.`
228
1447
  );
229
1448
  else {
230
- if (!["setup", "doctor"].includes(args[0]))
1449
+ if (!["setup", "doctor", "pair"].includes(args[0]))
231
1450
  throw new Error("Unknown command.");
232
1451
  if (args.slice(1).some(
233
- (value, i) => i % 2 === 0 && !["--config", "--port", "--client"].includes(value)
1452
+ (value, i) => i % 2 === 0 && !["--config", "--port", "--client", "--revoke"].includes(value)
234
1453
  ))
235
1454
  throw new Error("Unknown option.");
236
1455
  const clientOption = option("--client");
@@ -291,21 +1510,25 @@ args = ${JSON.stringify(server.args)}`
291
1510
  }
292
1511
  }
293
1512
  console.log(
294
- `Pairing: Firefox options > Import local configuration > ${configPath} > Enregistrer. The token stays in the private file.
295
- Chrome (manual install, not yet on the Chrome Web Store): open chrome://extensions, enable Developer mode, "Load unpacked", select ${join2(root, "chrome")}, then Options > Import local configuration.
1513
+ `Open your GazeSight browser extension and click Connect to GazeSight.
1514
+ Chrome (manual install, not yet on the Chrome Web Store): open chrome://extensions, enable Developer mode, "Load unpacked", select ${join2(root, "chrome")}.
296
1515
  Development only (Firefox): about:debugging > Load Temporary Add-on > ${join2(root, "firefox/manifest.json")}
297
1516
  Unsigned XPI: ${join2(root, "gazesight-firefox.xpi")}
298
1517
  External Firefox Release/Beta installation requires the separately supplied Mozilla-signed XPI.
299
- Start your coding agent, open your local app in your browser, click GazeSight, then run gazesight doctor.`
1518
+ Manual JSON import remains available under Advanced settings.`
300
1519
  );
1520
+ if (process.stdin.isTTY) await pairingFlow(config, configPath);
1521
+ }
1522
+ if (args[0] === "pair") {
1523
+ await pairingFlow(config, configPath, option("--revoke"));
301
1524
  }
302
- const result = await probe(config.port, config.token);
1525
+ const result = args[0] === "pair" ? void 0 : await probe(config.port, config.token);
303
1526
  const rows = diagnosticRows(
304
- result.status,
305
- result.reachable,
306
- result.authenticated
1527
+ result?.status,
1528
+ result?.reachable ?? false,
1529
+ result?.authenticated ?? false
307
1530
  );
308
- for (const row of rows)
1531
+ if (args[0] !== "pair") for (const row of rows)
309
1532
  console.log(
310
1533
  "status" in row ? `${row.name}: ${row.status}` : `${row.name}: ${row.pass ? "PASS" : `FAIL \u2014 ${row.action}`}`
311
1534
  );