breakpoint-mcp 1.14.0 → 1.19.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.
@@ -22,7 +22,7 @@ var sink: Callable
22
22
  func _log_message(message: String, error: bool) -> void:
23
23
  if sink.is_valid():
24
24
  sink.call("error" if error else "info", message)
25
- func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
25
+ func _log_error(_function: String, file: String, line: int, code: String, rationale: String, _editor_notify: bool, error_type: int, _script_backtraces: Array) -> void:
26
26
  if sink.is_valid():
27
27
  var lvl := "warning" if error_type == 1 else "error"
28
28
  var detail := rationale if rationale != "" else code
@@ -45,9 +45,17 @@ const MONITORS := {
45
45
  "audio/output_latency": Performance.AUDIO_OUTPUT_LATENCY,
46
46
  }
47
47
 
48
+ const BridgeSecret := preload("res://addons/breakpoint_mcp/bridge_secret.gd")
49
+ const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
50
+
48
51
  var _server: TCPServer
49
52
  var _clients: Array = [] # Array of {peer, buf}
50
53
  var _port: int = DEFAULT_PORT
54
+ ## Loopback-auth handshake state (default-on; see bridge_secret.gd), mirroring the
55
+ ## editor bridge_server. `_auth_required` is false only when auth is explicitly
56
+ ## disabled (BREAKPOINT_BRIDGE_INSECURE) or the secret can't be minted.
57
+ var _secret: String = ""
58
+ var _auth_required: bool = false
51
59
  var _log: Array = [] # ring buffer of {seq, level, message}
52
60
  var _log_seq: int = 0
53
61
  var _tree_dirty: bool = false
@@ -62,6 +70,7 @@ func _ready() -> void:
62
70
  var env_port := OS.get_environment("BREAKPOINT_RUNTIME_PORT")
63
71
  if env_port != "" and env_port.is_valid_int():
64
72
  _port = int(env_port)
73
+ _setup_auth()
65
74
  _server = TCPServer.new()
66
75
  var err := _server.listen(_port, "127.0.0.1")
67
76
  if err != OK:
@@ -79,6 +88,22 @@ func _ready() -> void:
79
88
  _install_log_capture()
80
89
 
81
90
 
91
+ ## Establish the loopback-auth secret unless explicitly disabled. Default-on:
92
+ ## BREAKPOINT_BRIDGE_INSECURE=1 (or =true) turns auth OFF (documented escape
93
+ ## hatch). If the secret can't be persisted, run WITHOUT auth rather than
94
+ ## bricking the bridge (a broken mint must not lock out the host).
95
+ func _setup_auth() -> void:
96
+ var insecure := OS.get_environment("BREAKPOINT_BRIDGE_INSECURE").to_lower()
97
+ if insecure == "1" or insecure == "true":
98
+ _auth_required = false
99
+ push_warning("[breakpoint_runtime] BREAKPOINT_BRIDGE_INSECURE set — loopback bridge auth DISABLED")
100
+ return
101
+ _secret = BridgeSecret.load_or_mint()
102
+ _auth_required = _secret != ""
103
+ if not _auth_required:
104
+ push_error("[breakpoint_runtime] could not establish bridge secret — running WITHOUT auth")
105
+
106
+
82
107
  ## Public API: game code can route its own logs here for runtime.get_log to read.
83
108
  func push_log(level: String, message: String) -> void:
84
109
  _log_seq += 1
@@ -119,7 +144,7 @@ func _process(_delta: float) -> void:
119
144
  while _server.is_connection_available():
120
145
  var peer := _server.take_connection()
121
146
  if peer:
122
- _clients.append({"peer": peer, "buf": ""})
147
+ _clients.append({"peer": peer, "buf": "", "authed": not _auth_required})
123
148
  var alive: Array = []
124
149
  for c in _clients:
125
150
  var peer: StreamPeerTCP = c["peer"]
@@ -134,6 +159,10 @@ func _process(_delta: float) -> void:
134
159
  var bytes: PackedByteArray = chunk[1]
135
160
  c["buf"] += bytes.get_string_from_utf8()
136
161
  _drain_lines(c)
162
+ if c.get("close", false):
163
+ peer.poll()
164
+ peer.disconnect_from_host()
165
+ continue
137
166
  alive.append(c)
138
167
  _clients = alive
139
168
  # D3 follow-up: one runtime-tree push per frame if the SceneTree changed.
@@ -154,25 +183,55 @@ func _drain_lines(c: Dictionary) -> void:
154
183
  var line := buf.substr(0, nl).strip_edges()
155
184
  buf = buf.substr(nl + 1)
156
185
  if line != "":
157
- _handle_line(c["peer"], line)
186
+ _handle_line(c, line)
187
+ if c.get("close", false):
188
+ break
158
189
  c["buf"] = buf
159
190
 
160
191
 
161
- func _handle_line(peer: StreamPeerTCP, line: String) -> void:
192
+ func _handle_line(c: Dictionary, line: String) -> void:
193
+ var peer: StreamPeerTCP = c["peer"]
162
194
  var parsed: Variant = JSON.parse_string(line)
163
195
  if typeof(parsed) != TYPE_DICTIONARY:
196
+ if not c.get("authed", false):
197
+ _deny_unauth(c)
198
+ return
164
199
  _send(peer, {"id": null, "ok": false, "error": {"code": "bad_json", "message": "Bad request"}})
165
200
  return
166
201
  var req: Dictionary = parsed
167
202
  var id: Variant = req.get("id", null)
168
203
  var method := String(req.get("method", ""))
169
204
  var params: Dictionary = req.get("params", {}) if typeof(req.get("params")) == TYPE_DICTIONARY else {}
205
+ # Handshake gate: an unauthenticated peer may ONLY authenticate.
206
+ if not c.get("authed", false):
207
+ if method == "auth" and BridgeSecret.const_time_eq(String(params.get("secret", "")), _secret):
208
+ c["authed"] = true
209
+ _send(peer, {"id": id, "ok": true})
210
+ else:
211
+ _deny_unauth(c)
212
+ return
213
+ # Pause latch (addon "Pause Agent" control): honor the same editor-set latch the
214
+ # editor bridge does. While paused, HOLD every new command except a liveness
215
+ # `ping` — reject without dispatching; the running game is otherwise untouched.
216
+ # Read cross-process from res://.godot/ (the editor writes it; see pause_latch.gd).
217
+ if method != "ping" and PauseLatch.is_paused():
218
+ var held := {"id": id}
219
+ held.merge(PauseLatch.held_response(method))
220
+ _send(peer, held)
221
+ return
170
222
  var result := _dispatch(method, params)
171
223
  var response := {"id": id}
172
224
  response.merge(result)
173
225
  _send(peer, response)
174
226
 
175
227
 
228
+ ## Generic, no-echo denial for an unauthenticated peer; marks the connection for
229
+ ## closing. Never reveals the expected secret, the received value, or any detail.
230
+ func _deny_unauth(c: Dictionary) -> void:
231
+ _send(c["peer"], {"id": null, "ok": false, "error": {"code": "unauthorized"}})
232
+ c["close"] = true
233
+
234
+
176
235
  func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
177
236
  peer.put_data((JSON.stringify(obj) + "\n").to_utf8_buffer())
178
237
 
@@ -184,7 +243,7 @@ func _send(peer: StreamPeerTCP, obj: Dictionary) -> void:
184
243
  func broadcast_event(uri: String) -> void:
185
244
  for c in _clients:
186
245
  var peer: StreamPeerTCP = c["peer"]
187
- if peer and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
246
+ if peer and c.get("authed", false) and peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
188
247
  _send(peer, {"event": "resource.changed", "uri": uri})
189
248
 
190
249
 
@@ -255,6 +314,16 @@ func _dispatch(method: String, params: Dictionary) -> Dictionary:
255
314
  return _screenshot()
256
315
  "runtime.get_log":
257
316
  return _get_log(params)
317
+ "runtime.assert_node_state":
318
+ return _assert_node_state(params)
319
+ "runtime.assert_scene_structure":
320
+ return _assert_scene_structure(params)
321
+ "runtime.assert_perf":
322
+ return _assert_perf(params)
323
+ "runtime.assert_screen_text":
324
+ return _assert_screen_text(params)
325
+ "runtime.screenshot_diff":
326
+ return _screenshot_diff(params)
258
327
  _:
259
328
  return _err("unknown_method", "No such method: %s" % method)
260
329
 
@@ -438,3 +507,263 @@ func _get_log(params: Dictionary) -> Dictionary:
438
507
  if int(e["seq"]) > since and (levels.is_empty() or levels.has(e["level"])):
439
508
  entries.append(e)
440
509
  return _ok({"entries": entries, "latest_seq": _log_seq, "capture": _log_capture != null})
510
+
511
+
512
+ func _assert_node_state(params: Dictionary) -> Dictionary:
513
+ var node := _resolve(String(params.get("path", "")))
514
+ if node == null:
515
+ return _err("bad_path", "Node not found: %s" % params.get("path", ""))
516
+ var raw_expect: Variant = params.get("expect", {})
517
+ var expect: Dictionary = raw_expect if typeof(raw_expect) == TYPE_DICTIONARY else {}
518
+ var tol := float(params.get("tolerance", 0.0))
519
+ var mismatches: Array = []
520
+ for prop in expect.keys():
521
+ var key := String(prop)
522
+ var expected: Variant = expect[key]
523
+ var actual_encoded: Variant = Codec.encode(node.get(key))
524
+ if not _values_match(expected, actual_encoded, tol):
525
+ mismatches.append({"property": key, "expected": expected, "actual": actual_encoded})
526
+ return _ok({
527
+ "path": _path_of(node),
528
+ "ok": mismatches.is_empty(),
529
+ "checked": expect.size(),
530
+ "mismatches": mismatches,
531
+ })
532
+
533
+
534
+ func _assert_scene_structure(params: Dictionary) -> Dictionary:
535
+ var raw: Variant = params.get("expect", [])
536
+ var expect: Array = raw if typeof(raw) == TYPE_ARRAY else []
537
+ var failures: Array = []
538
+ for entry_v in expect:
539
+ if typeof(entry_v) != TYPE_DICTIONARY:
540
+ continue
541
+ var entry: Dictionary = entry_v
542
+ var path := String(entry.get("path", ""))
543
+ var absent := bool(entry.get("absent", false))
544
+ var node := _resolve(path)
545
+ if absent:
546
+ if node != null:
547
+ failures.append({"path": path, "reason": "expected_absent_but_present"})
548
+ continue
549
+ if node == null:
550
+ failures.append({"path": path, "reason": "missing"})
551
+ continue
552
+ if entry.has("type"):
553
+ var want_type := String(entry["type"])
554
+ if not node.is_class(want_type) and node.get_class() != want_type:
555
+ failures.append({"path": path, "reason": "type_mismatch", "expected": want_type, "actual": node.get_class()})
556
+ return _ok({
557
+ "ok": failures.is_empty(),
558
+ "checked": expect.size(),
559
+ "failures": failures,
560
+ })
561
+
562
+
563
+ func _values_match(expected: Variant, actual: Variant, tol: float) -> bool:
564
+ var te := typeof(expected)
565
+ var ta := typeof(actual)
566
+ var expected_num := te == TYPE_INT or te == TYPE_FLOAT
567
+ var actual_num := ta == TYPE_INT or ta == TYPE_FLOAT
568
+ if expected_num and actual_num:
569
+ return absf(float(actual) - float(expected)) <= tol
570
+ return _deep_equal(expected, actual)
571
+
572
+
573
+ func _deep_equal(a: Variant, b: Variant) -> bool:
574
+ var ta := typeof(a)
575
+ var tb := typeof(b)
576
+ if ta != tb:
577
+ var an := ta == TYPE_INT or ta == TYPE_FLOAT
578
+ var bn := tb == TYPE_INT or tb == TYPE_FLOAT
579
+ if an and bn:
580
+ return float(a) == float(b)
581
+ return false
582
+ if ta == TYPE_DICTIONARY:
583
+ var da: Dictionary = a
584
+ var db: Dictionary = b
585
+ if da.size() != db.size():
586
+ return false
587
+ for k in da.keys():
588
+ if not db.has(k):
589
+ return false
590
+ if not _deep_equal(da[k], db[k]):
591
+ return false
592
+ return true
593
+ if ta == TYPE_ARRAY:
594
+ var aa: Array = a
595
+ var ba: Array = b
596
+ if aa.size() != ba.size():
597
+ return false
598
+ for i in aa.size():
599
+ if not _deep_equal(aa[i], ba[i]):
600
+ return false
601
+ return true
602
+ return a == b
603
+
604
+
605
+ func _assert_perf(params: Dictionary) -> Dictionary:
606
+ var raw_baseline: Variant = params.get("baseline", {})
607
+ var baseline: Dictionary = raw_baseline if typeof(raw_baseline) == TYPE_DICTIONARY else {}
608
+ var tol := float(params.get("tolerance", 0.0))
609
+ var raw_dir: Variant = params.get("direction", {})
610
+ var dir_overrides: Dictionary = raw_dir if typeof(raw_dir) == TYPE_DICTIONARY else {}
611
+ var regressions: Array = []
612
+ var monitors := {}
613
+ var checked := 0
614
+ for key_v in baseline.keys():
615
+ var key := String(key_v)
616
+ if not MONITORS.has(key):
617
+ continue
618
+ checked += 1
619
+ var current := float(Performance.get_monitor(MONITORS[key]))
620
+ var base_val := float(baseline[key])
621
+ monitors[key] = current
622
+ var direction := "higher_better" if key == "time/fps" else "lower_better"
623
+ if dir_overrides.has(key):
624
+ direction = String(dir_overrides[key])
625
+ var passed := true
626
+ if direction == "higher_better":
627
+ passed = current >= base_val * (1.0 - tol)
628
+ else:
629
+ passed = current <= base_val * (1.0 + tol)
630
+ if not passed:
631
+ regressions.append({"key": key, "baseline": base_val, "current": current, "direction": direction})
632
+ return _ok({
633
+ "ok": regressions.is_empty(),
634
+ "checked": checked,
635
+ "regressions": regressions,
636
+ "monitors": monitors,
637
+ })
638
+
639
+
640
+ func _text_of(node: Node) -> String:
641
+ # The visible text a Control exposes via its `text` property (Label / Button /
642
+ # LineEdit / TextEdit / RichTextLabel / CheckBox / LinkButton …). Non-text nodes
643
+ # return null from get() and are skipped.
644
+ var v: Variant = node.get("text")
645
+ if v is String:
646
+ return String(v)
647
+ return ""
648
+
649
+
650
+ func _assert_screen_text(params: Dictionary) -> Dictionary:
651
+ var needle := String(params.get("text", ""))
652
+ var present := bool(params.get("present", true))
653
+ var use_regex := bool(params.get("regex", false))
654
+ var case_sensitive := bool(params.get("case_sensitive", false))
655
+ var has_min := params.has("min_count")
656
+ var min_count := int(params.get("min_count", 0))
657
+ var re: RegEx = null
658
+ if use_regex:
659
+ re = RegEx.new()
660
+ var pattern := needle if case_sensitive else "(?i)" + needle
661
+ if re.compile(pattern) != OK:
662
+ return _err("bad_regex", "Invalid regex: %s" % needle)
663
+ var samples: Array = []
664
+ var count := 0
665
+ var base := _base()
666
+ if base != null:
667
+ var stack: Array = [base]
668
+ while not stack.is_empty():
669
+ var node: Node = stack.pop_back()
670
+ for child in node.get_children():
671
+ stack.append(child)
672
+ if not (node is CanvasItem):
673
+ continue
674
+ if not (node as CanvasItem).is_visible_in_tree():
675
+ continue
676
+ var txt := _text_of(node)
677
+ if txt == "":
678
+ continue
679
+ var matched := false
680
+ if use_regex:
681
+ matched = re.search(txt) != null
682
+ elif case_sensitive:
683
+ matched = txt.contains(needle)
684
+ else:
685
+ matched = txt.to_lower().contains(needle.to_lower())
686
+ if matched:
687
+ count += 1
688
+ if samples.size() < 20:
689
+ samples.append({"path": _path_of(node), "text": txt})
690
+ var ok := (count > 0) if present else (count == 0)
691
+ if present and has_min:
692
+ ok = count >= min_count
693
+ return _ok({
694
+ "ok": ok,
695
+ "matches": count,
696
+ "present": present,
697
+ "samples": samples,
698
+ })
699
+
700
+
701
+ func _screenshot_diff(params: Dictionary) -> Dictionary:
702
+ var reference := String(params.get("reference", ""))
703
+ var vp := get_viewport()
704
+ if vp == null:
705
+ return _err("no_viewport", "No viewport")
706
+ var tex := vp.get_texture()
707
+ if tex == null:
708
+ return _err("no_texture", "No viewport texture")
709
+ var img := tex.get_image()
710
+ if img == null:
711
+ return _err("no_image", "Could not read frame")
712
+ var ref_img := Image.new()
713
+ var err := ref_img.load(reference)
714
+ if err != OK:
715
+ return _err("bad_reference", "Could not load reference image: %s (error %d)" % [reference, err])
716
+ return _compare_images(img, ref_img, params, reference)
717
+
718
+
719
+ func _compare_images(frame: Image, reference_img: Image, params: Dictionary, reference: String) -> Dictionary:
720
+ var tolerance := float(params.get("tolerance", 0.0))
721
+ var per_channel := int(params.get("per_channel_threshold", 0))
722
+ # Work on copies normalized to RGBA8 so get_data() is a byte-aligned comparison.
723
+ var a := Image.new()
724
+ a.copy_from(frame)
725
+ var b := Image.new()
726
+ b.copy_from(reference_img)
727
+ a.convert(Image.FORMAT_RGBA8)
728
+ b.convert(Image.FORMAT_RGBA8)
729
+ var region_v: Variant = params.get("region")
730
+ if region_v is Dictionary:
731
+ var rg: Dictionary = region_v
732
+ var rect := Rect2i(int(rg.get("x", 0)), int(rg.get("y", 0)), int(rg.get("w", 0)), int(rg.get("h", 0)))
733
+ a = a.get_region(rect)
734
+ b = b.get_region(rect)
735
+ var w := a.get_width()
736
+ var h := a.get_height()
737
+ if w != b.get_width() or h != b.get_height():
738
+ return _ok({
739
+ "ok": false,
740
+ "reason": "dimension_mismatch",
741
+ "diff_ratio": 1.0,
742
+ "differing_pixels": 0,
743
+ "total_pixels": 0,
744
+ "width": w,
745
+ "height": h,
746
+ "reference": reference,
747
+ })
748
+ var total := w * h
749
+ var da := a.get_data()
750
+ var db := b.get_data()
751
+ var differing := 0
752
+ var n := da.size()
753
+ var i := 0
754
+ while i < n:
755
+ for c in 4:
756
+ if absi(int(da[i + c]) - int(db[i + c])) > per_channel:
757
+ differing += 1
758
+ break
759
+ i += 4
760
+ var ratio := (float(differing) / float(total)) if total > 0 else 0.0
761
+ return _ok({
762
+ "ok": ratio <= tolerance,
763
+ "diff_ratio": ratio,
764
+ "differing_pixels": differing,
765
+ "total_pixels": total,
766
+ "width": w,
767
+ "height": h,
768
+ "reference": reference,
769
+ })
@@ -14,6 +14,8 @@ extends VBoxContainer
14
14
  ## thread never stalls. LSP/DAP ports are read from EditorSettings, so the dock
15
15
  ## reflects the user's actual configuration, not just the defaults.
16
16
 
17
+ const PauseLatch := preload("res://addons/breakpoint_mcp/pause_latch.gd")
18
+
17
19
  const DEFAULT_BRIDGE_PORT := 9080
18
20
  const DEFAULT_RUNTIME_PORT := 9081
19
21
  const DEFAULT_LSP_PORT := 6005
@@ -25,6 +27,7 @@ const PROBE_DEADLINE_MSEC := 1500
25
27
  const COLOR_OK := Color(0.38, 0.85, 0.45)
26
28
  const COLOR_FAIL := Color(0.92, 0.47, 0.40)
27
29
  const COLOR_PENDING := Color(0.62, 0.62, 0.62)
30
+ const COLOR_PAUSED := Color(0.95, 0.73, 0.30)
28
31
 
29
32
  const PLANES := [
30
33
  {"key": "editor", "name": "editor-bridge"},
@@ -75,6 +78,8 @@ var _probes := {} # plane key -> {peer, deadline, port}
75
78
  var _config_label: Label = null
76
79
  var _copy_feedback: Label = null
77
80
  var _timer: Timer = null
81
+ var _pause_toggle: CheckButton = null
82
+ var _pause_state: Label = null
78
83
 
79
84
 
80
85
  ## plugin.gd injects the live bridge server so the editor-bridge plane is read
@@ -133,6 +138,27 @@ func _build_ui() -> void:
133
138
  _set_plane(p["key"], "pending", "…")
134
139
  add_child(HSeparator.new())
135
140
 
141
+ # Control: a one-click "Pause Agent" latch honored by the editor + runtime
142
+ # bridges (pause_latch.gd). Engaged, those two planes hold new agent commands
143
+ # until resumed — the visible companion to the host signal latch (whole-surface).
144
+ var ctrl_hdr := Label.new()
145
+ ctrl_hdr.text = "Control"
146
+ ctrl_hdr.modulate = Color(1, 1, 1, 0.75)
147
+ add_child(ctrl_hdr)
148
+
149
+ _pause_toggle = CheckButton.new()
150
+ _pause_toggle.text = "Pause Agent"
151
+ _pause_toggle.tooltip_text = "Hold new agent commands on the editor + runtime bridges. In-flight ops finish and a bare ping still answers; resume to continue."
152
+ _pause_toggle.toggled.connect(_on_pause_toggled)
153
+ add_child(_pause_toggle)
154
+
155
+ _pause_state = Label.new()
156
+ _pause_state.add_theme_font_size_override("font_size", 12)
157
+ _pause_state.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
158
+ add_child(_pause_state)
159
+
160
+ add_child(HSeparator.new())
161
+
136
162
  var cfg_hdr := Label.new()
137
163
  cfg_hdr.text = "Config"
138
164
  cfg_hdr.modulate = Color(1, 1, 1, 0.75)
@@ -197,6 +223,7 @@ func _refresh() -> void:
197
223
  _start_probe("lsp", _lsp_port())
198
224
  _start_probe("dap", _dap_port())
199
225
  _update_config_line()
226
+ _update_pause_ui()
200
227
 
201
228
 
202
229
  func _update_editor_plane() -> void:
@@ -227,6 +254,27 @@ func _on_copy_pressed() -> void:
227
254
  _copy_feedback.text = "Copied the mcpServers snippet to the clipboard."
228
255
 
229
256
 
257
+ func _on_pause_toggled(pressed: bool) -> void:
258
+ PauseLatch.set_paused(pressed)
259
+ _update_pause_ui()
260
+
261
+
262
+ ## Reflect the latch state in the toggle + label. Called on every refresh so an
263
+ ## external change (plugin enable/disable clears it, or the host) shows here too;
264
+ ## set_pressed_no_signal avoids re-firing `toggled` back into set_paused.
265
+ func _update_pause_ui() -> void:
266
+ if _pause_toggle == null or _pause_state == null:
267
+ return
268
+ var paused := PauseLatch.is_paused()
269
+ _pause_toggle.set_pressed_no_signal(paused)
270
+ if paused:
271
+ _pause_state.text = "PAUSED — editor + runtime bridges are holding new agent commands."
272
+ _pause_state.add_theme_color_override("font_color", COLOR_PAUSED)
273
+ else:
274
+ _pause_state.text = "Running — agent commands flow normally."
275
+ _pause_state.add_theme_color_override("font_color", COLOR_OK)
276
+
277
+
230
278
  # --- ports -----------------------------------------------------------------
231
279
 
232
280
  func _project_path() -> String:
package/dist/bridge.js CHANGED
@@ -27,6 +27,7 @@ export class BridgeClient {
27
27
  defaultTimeoutMs;
28
28
  label;
29
29
  hint;
30
+ secretProvider;
30
31
  socket = null;
31
32
  connecting = null;
32
33
  buffer = "";
@@ -34,12 +35,21 @@ export class BridgeClient {
34
35
  eventListeners = new Set();
35
36
  wantConnected = false;
36
37
  reconnectTimer = null;
37
- constructor(host, port, defaultTimeoutMs, label = "editor bridge", hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled?') {
38
+ constructor(host, port, defaultTimeoutMs, label = "editor bridge", hint = 'Is the editor open with the "Breakpoint MCP" plugin enabled?',
39
+ /**
40
+ * Loopback-auth secret provider, read lazily on each connect. When it
41
+ * returns a non-empty string the client sends it as the FIRST line on the
42
+ * connection (see connect()); null/undefined → no auth line, matching an
43
+ * insecure or not-yet-provisioned bridge. Lazy so a secret minted after the
44
+ * host started (editor launched later) is picked up on the next (re)connect.
45
+ */
46
+ secretProvider) {
38
47
  this.host = host;
39
48
  this.port = port;
40
49
  this.defaultTimeoutMs = defaultTimeoutMs;
41
50
  this.label = label;
42
51
  this.hint = hint;
52
+ this.secretProvider = secretProvider;
43
53
  }
44
54
  /** Register a listener for addon-pushed resource-change events. */
45
55
  onResourceChanged(cb) {
@@ -57,6 +67,17 @@ export class BridgeClient {
57
67
  this.socket = socket;
58
68
  this.connecting = null;
59
69
  this.clearReconnect();
70
+ // Loopback-auth handshake: if a secret is available it MUST be the first
71
+ // line on the connection. TCP preserves order and the addon drains lines
72
+ // sequentially, so this is guaranteed to be processed before any request
73
+ // line — no await needed. With no secret we send nothing and behave
74
+ // exactly as before (backward-compatible with an insecure bridge). The
75
+ // addon's auth reply carries no id, so onMessage() ignores it; on a
76
+ // failed handshake the addon closes the socket and onClose() reconnects.
77
+ const secret = this.secretProvider?.() ?? null;
78
+ if (secret) {
79
+ socket.write(JSON.stringify({ method: "auth", params: { secret } }) + "\n");
80
+ }
60
81
  log(`bridge connected to ${this.host}:${this.port}`);
61
82
  resolve(socket);
62
83
  });