breakpoint-mcp 1.28.0 โ†’ 1.72.7

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 (45) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +4 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +49 -5
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +72 -1
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. package/package.json +8 -2
package/dist/tools/dap.js CHANGED
@@ -1,18 +1,130 @@
1
1
  import { z } from "zod";
2
2
  import { DapError } from "../dap.js";
3
- import { toFsPath } from "../paths.js";
3
+ import { resolveSourceFile } from "../paths.js";
4
4
  import { gate } from "../confirm.js";
5
5
  import { ok } from "./lsp-common.js";
6
6
  import { portFree, portConflictMessage } from "../ports.js";
7
+ /**
8
+ * How long `stop_on_entry: true` waits for the entry `stopped` event before
9
+ * reporting that the adapter did not honour it. Short on purpose: Godot 4.7 ignores
10
+ * `stopOnEntry` outright (measured โ€” the game ran to completion and printed its
11
+ * `_ready()` line), so this is the price of the honest answer on every launch, not a
12
+ * rare timeout. An adapter that DOES honour it stops well inside the window.
13
+ */
14
+ const ENTRY_STOP_WAIT_MS = 3000;
15
+ /** A refusal raised by a guard here, rendered verbatim rather than as a DAP error. */
16
+ class DapRefusal extends Error {
17
+ reason;
18
+ /**
19
+ * ๐Ÿ”ด 162: the SAME marker `paths.ts`, `lsp-common.fail()` and `csdap.ts` already use.
20
+ * Before this, dap.ts recognised a refusal only by `instanceof DapRefusal`, so a
21
+ * refusal raised anywhere else โ€” including by the shared guard this file now calls โ€”
22
+ * would have been rendered as `DAP error [error]: Refusing "โ€ฆ"`, sending the caller
23
+ * to debug an adapter that was never asked. One refusal contract, four planes.
24
+ */
25
+ refusal = true;
26
+ constructor(message, reason) {
27
+ super(message);
28
+ this.reason = reason;
29
+ this.name = "DapRefusal";
30
+ }
31
+ }
32
+ /**
33
+ * This plane's shipped refusal wording, kept verbatim while the guard moved to
34
+ * `paths.ts` (161 ยง8 item 5). `gdscript-dap-plane.integration.mjs` pins every string
35
+ * here BY REASON โ€” /outside the Godot project root/, /no such file/, /is not a file/,
36
+ * and for the empty path additionally /project root/.
37
+ *
38
+ * ๐Ÿ”ด The escape check is deliberately WIDER than the `cs_dbg_*` plane's, and 162
39
+ * measured both to confirm the difference is real rather than drift. `cs_dbg_launch`
40
+ * documents overriding `program` to debug a DIFFERENT .NET program, whose sources
41
+ * legitimately live outside the Godot C# project. `dbg_launch` has no such mainline:
42
+ * its `scene` is `'main'`, `'current'` or a `res://` path, and Godot binds breakpoints
43
+ * only to scripts in the project it runs โ€” so an absolute path outside the root can
44
+ * never bind here whichever way it is spelled, and all three forms stay anchored.
45
+ * Absolute paths INSIDE the project stay legal; the probe uses that form and two
46
+ * over-eager mutations exist to keep it working.
47
+ */
48
+ const GD_DAP_PATHS = {
49
+ root: "the Godot project root",
50
+ escapeHint: "Godot binds breakpoints only to scripts in the project it runs, so a breakpoint " +
51
+ "there can never bind.",
52
+ missingHint: "A breakpoint there can never bind, but it was previously answered exactly like " +
53
+ "one on a real script.",
54
+ emptyNote: " (an empty path resolves to the project root)",
55
+ };
56
+ /**
57
+ * `dbg_launch` / `dbg_restart`'s `scene`, which is a path parameter that is not
58
+ * called `path` โ€” which is exactly why 162's sweep never reached it (163 ยง2).
59
+ *
60
+ * ๐Ÿ”ด MEASURED against a real 4.7 adapter before a line of this was written, because
61
+ * the obvious rule would have broken two spellings that work. Every row below is a
62
+ * launch that was actually performed and whose game console was read back over the
63
+ * DAP `output` event, so "did it run" is the engine's answer, not an inference:
64
+ *
65
+ * main ยท current ยท res://demo/demo.tscn ยท <abs inside>/demo/demo.tscn ยท uid://<known>
66
+ * -> the REQUESTED scene ran. The last three are the load-bearing ones: each
67
+ * ran a scene that is NOT the main scene, so they prove the spelling is
68
+ * honoured rather than silently falling back.
69
+ * res://../example_evil/โ€ฆ ยท ../example_evil/โ€ฆ ยท <root>_evil/โ€ฆ ยท /elsewhere/โ€ฆ
70
+ * -> ๐Ÿ”ด NOTHING ran, and the session stayed `running` with a live, sceneless
71
+ * game process. `dbg_stack_trace` then answered `{"frames":[]}` โ€” BYTE
72
+ * IDENTICAL to a healthy running session. The caller could not find out.
73
+ * "" -> ๐Ÿ”ด nothing ran, NO game process existed at all, session still `running`.
74
+ * /etc/passwd ยท res://NoSuchFile.tscn ยท res://tests (a directory)
75
+ * -> nothing ran; the session terminated a moment later, so those at least
76
+ * announced themselves.
77
+ *
78
+ * So NOTHING ESCAPES โ€” Godot never ran an out-of-project scene, whichever way it was
79
+ * spelled. The defect is not containment, it is that `dbg_launch` answered
80
+ * `{"state":"running"}` for every one of them. That is the same shape as the
81
+ * `dbg_set_breakpoints` defect above it in this file โ€” an answer that cannot
82
+ * distinguish "armed" from "can never bind" โ€” one tool over on the same plane.
83
+ *
84
+ * ๐Ÿ”ด uid:// IS EXEMPT AND THAT EXEMPTION IS MEASURED, NOT ASSUMED. Godot 4 writes a
85
+ * UID into every scene header, and `uid://bq3k7x2yv8n1a` RAN the scene it names. A
86
+ * uid names a project resource through the engine's own map, never a filesystem
87
+ * location, so it cannot express an escape โ€” and requiring it to exist on disk would
88
+ * refuse a working spelling to fix a broken one. That is precisely the mistake 162
89
+ * ยง5 D2 caught itself about to make on `csdap`.
90
+ *
91
+ * ๐Ÿ”ด WHAT THIS DELIBERATELY DOES NOT FIX, because it was measured and is a different
92
+ * defect: an UNKNOWN uid (`uid://zzzzzzzzzzzzz`) silently ran the MAIN scene while
93
+ * the tool echoed the uid back. Resolving that needs the engine's UID map, which the
94
+ * host does not have. Recorded in the description and the handoff rather than guessed
95
+ * at. Likewise `res://player.gd` โ€” a real file inside the root that is not a scene โ€”
96
+ * stays legal here and terminates the session, which at least announces itself.
97
+ */
98
+ const GD_DAP_SCENE = {
99
+ root: "the Godot project root",
100
+ escapeHint: "Godot runs scenes from the project it launched, so a scene outside the root never " +
101
+ "loads: measured, the game starts with no scene and the session reports `running` " +
102
+ "forever. Pass 'main', 'current', a scene inside the project, or a uid:// reference.",
103
+ missingHint: "The adapter accepts the launch and nothing runs, so this was previously reported " +
104
+ "as a started session.",
105
+ emptyNote: " (an empty scene resolves to the project root)",
106
+ };
107
+ /** Scene spellings the adapter resolves itself; neither names a file. */
108
+ const SCENE_SENTINELS = new Set(["main", "current"]);
7
109
  // How long step/continue wait for the program to settle (hit a breakpoint,
8
110
  // finish a step, or terminate) before returning. On timeout the tool reports
9
111
  // the current state โ€” e.g. `continue` with no further breakpoint stays running.
10
112
  const RESUME_WAIT_MS = 15000;
11
113
  function fail(err) {
114
+ // A refusal this file raised is the host's own answer โ€” rendering it as
115
+ // `DAP error [...]` would send the caller off to debug an adapter that was never
116
+ // asked. Same distinction lsp-common.fail() already draws for the LSP planes.
12
117
  const e = err;
118
+ if (err instanceof DapRefusal || e?.refusal) {
119
+ return { isError: true, content: [{ type: "text", text: e.message ?? String(err) }] };
120
+ }
121
+ // An adapter can answer a failure with an EMPTY message (netcoredbg did, on
122
+ // setVariable). `?? String(err)` only covers an ABSENT one, so the whole answer
123
+ // became a label and a colon.
124
+ const detail = e.message && e.message.trim() !== "" ? e.message : String(err);
13
125
  return {
14
126
  isError: true,
15
- content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${e.message ?? String(err)}` }],
127
+ content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}` }],
16
128
  };
17
129
  }
18
130
  /**
@@ -23,46 +135,125 @@ function fail(err) {
23
135
  function isDapTimeout(err) {
24
136
  return err instanceof DapError && /timed out after/.test(err.message);
25
137
  }
26
- // Per-line breakpoint modifier fields, each gated by an adapter capability. Godot 4.3
27
- // advertises all three false AND ignores them (a "conditional" breakpoint would halt
28
- // unconditionally โ€” verified live in the dap-plane's editor-dap-breakpoints probe), so we
29
- // feature-detect: drop an unsupported modifier and warn, mirroring the
30
- // dbg_set_exception_breakpoints / dbg_goto / dbg_data_breakpoints discipline.
31
- const BREAKPOINT_MODIFIER_CAPS = {
32
- condition: "supportsConditionalBreakpoints",
33
- hitCondition: "supportsHitConditionalBreakpoints",
34
- logMessage: "supportsLogPoints",
35
- };
36
138
  /**
37
- * Which of condition/hitCondition/logMessage the connected adapter does NOT support, out of
38
- * the ones actually requested. Returns [] when capabilities are unknown (no session yet) โ€”
39
- * we can only feature-detect once the adapter has advertised what it supports.
139
+ * ๐Ÿ”ด The modifier feature-detection lives in `dap.ts` (the client), NOT here, and that
140
+ * is the fix this release carries. It was here, at SET time, where `dap.capabilities`
141
+ * is null for every breakpoint buffered before launch โ€” which is the documented and
142
+ * ordinary way to arm one. The guard was correct and unreachable: measured live, a
143
+ * pre-launch `conditions: ["counter < 0"]` was sent verbatim to an adapter that ignores
144
+ * it, the breakpoint halted every frame, and the result carried no warning at all.
145
+ * Detection now happens where the modifiers go on the wire, so every path gets it.
40
146
  */
41
- function unsupportedBreakpointModifiers(caps, requested) {
42
- if (!caps)
43
- return [];
44
- const out = [];
45
- for (const field of ["condition", "hitCondition", "logMessage"]) {
46
- if (requested[field] && caps[BREAKPOINT_MODIFIER_CAPS[field]] !== true)
47
- out.push(field);
48
- }
49
- return out;
50
- }
51
- /** True when a per-line modifier array carries at least one non-null, non-empty entry. */
52
- function hasModifier(arr) {
53
- return Array.isArray(arr) && arr.some((v) => v != null && v !== "");
54
- }
147
+ const warnDropped = (dropped) => `The connected Godot debug adapter does not support ${dropped.join(", ")} on breakpoints (it advertises ` +
148
+ `${dropped.length > 1 ? "them" : "it"} unsupported), so ${dropped.length > 1 ? "they were" : "it was"} dropped โ€” ` +
149
+ `the affected breakpoint(s) will halt unconditionally.`;
55
150
  export function registerDapTools(server, dap, cfg) {
151
+ /**
152
+ * Refuse when no launch/attach the adapter accepted is in force.
153
+ *
154
+ * ๐Ÿ”ด Measured on a client that had never launched anything: `dbg_continue` and
155
+ * `dbg_step` answered `isError:false {"state":"running"}`, `dbg_stack_trace`
156
+ * answered `{"frames":[]}` and `dbg_scopes` `{"scopes":[]}` โ€” all four
157
+ * indistinguishable from the same calls against a real session. Worse,
158
+ * `resume()`'s optimistic `state = "running"` MEANT THE FIRST SUCH CALL LEFT THE
159
+ * CLIENT LOOKING LIVE, so everything after it read as a genuine session. Only
160
+ * `dbg_restart` refused, and only because it happens to read `lastStartMode`.
161
+ */
162
+ const requireSession = (tool) => {
163
+ if (dap.hasSession)
164
+ return;
165
+ throw new DapRefusal(`${tool} needs a debug session: call dbg_launch or dbg_attach first. ` +
166
+ `There is none, so any state, frame or scope reported here would be invented โ€” ` +
167
+ `this call previously answered as if the program were running.`, "no_session");
168
+ };
169
+ /**
170
+ * Report, on a launch/attach result, any breakpoint modifier the handshake dropped
171
+ * when it applied the buffered breakpoints.
172
+ *
173
+ * ๐Ÿ”ด This is the other half of the buffered-modifier fix. `dbg_set_breakpoints` can
174
+ * only say "deferred" before a session exists; this is where the caller finds out what
175
+ * actually happened. Without it the honest "deferred" answer would have no follow-up
176
+ * and the caller would still be left guessing.
177
+ */
178
+ const reportDroppedModifiers = (result) => {
179
+ const dropped = dap.droppedBreakpointModifiers();
180
+ if (!dropped.length)
181
+ return;
182
+ result.unsupported_modifiers = dropped;
183
+ // Never clobber a warning the caller needs more (stop_on_entry's); append.
184
+ const note = `Buffered breakpoint modifiers were applied during this handshake: ${warnDropped(dropped)}`;
185
+ result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
186
+ };
187
+ /**
188
+ * Resolve a breakpoint source, refusing one that can never bind.
189
+ *
190
+ * ๐Ÿ”ด Measured: `res://NoSuchFile.gd`, `res://tests` (a DIRECTORY) and `""` โ€” which
191
+ * resolves to the project root โ€” each answered `{"buffered":true,"breakpoints":[]}`
192
+ * with `isError:false`, so a caller could not tell an armed breakpoint from one
193
+ * that can never bind. `res://../../../etc/passwd` resolved to `/private/etc/passwd`
194
+ * and was accepted too.
195
+ *
196
+ * ๐Ÿ”ด THE ESCAPE CHECK IS DELIBERATELY WIDER THAN THE `cs_dbg_*` PLANE'S, and the
197
+ * difference is the point rather than an inconsistency. `cs_dbg_launch` documents
198
+ * overriding `program` to debug a DIFFERENT .NET program, whose sources
199
+ * legitimately live outside the Godot project, so #166 kept an absolute path
200
+ * elsewhere legal. `dbg_launch` has no such mainline: its `scene` is `'main'`,
201
+ * `'current'` or a `res://` path, and Godot binds breakpoints only to scripts in
202
+ * the project it is running. An absolute path outside the root can never bind here
203
+ * whichever way it is spelled, so ALL THREE FORMS are anchored to the root.
204
+ * Absolute paths INSIDE the project stay legal โ€” that form is documented, the probe
205
+ * uses it, and two over-eager mutations exist to keep it working.
206
+ *
207
+ * The comparison is against `root + path.sep`, never a bare `startsWith(root)`:
208
+ * the latter accepts a sibling directory that merely shares the root's name prefix.
209
+ */
210
+ const guardSource = (p) => resolveSourceFile(p, cfg.projectPath, GD_DAP_PATHS);
211
+ /**
212
+ * `scene` for `dbg_launch` / `dbg_restart`. See GD_DAP_SCENE for the measurement.
213
+ *
214
+ * ๐Ÿ”ด Returns the ORIGINAL SPELLING, never the resolved path. `res://demo/demo.tscn`
215
+ * and `<abs>/demo/demo.tscn` were both measured running the demo scene, so what
216
+ * reaches the adapter must not change โ€” this guard only ever ADDS a refusal, which
217
+ * is what makes it behaviour-preserving on every spelling that already worked.
218
+ *
219
+ * ๐Ÿ”ด It also refuses BEFORE the port check and before the transport, deliberately:
220
+ * that is the property 162 ยง2 identified as making a guard measurable with no
221
+ * backend at all, and it is why the unit tests below need no editor. The one
222
+ * behaviour change is a call that has BOTH a bad scene and a bound bridge port โ€”
223
+ * it now names the scene instead of the port. That is the more actionable of the
224
+ * two answers, and it is pinned by a test rather than left to chance.
225
+ */
226
+ const guardScene = (s) => {
227
+ if (s === undefined)
228
+ return;
229
+ if (SCENE_SENTINELS.has(s))
230
+ return;
231
+ if (s.startsWith("uid://"))
232
+ return;
233
+ resolveSourceFile(s, cfg.projectPath, GD_DAP_SCENE);
234
+ };
56
235
  server.registerTool("dbg_launch", {
57
236
  title: "Launch debug session",
58
- description: "Start the game under the debugger. scene may be 'main', 'current', or a res:// scene path. " +
237
+ description: "Start the game under the debugger. scene may be 'main', 'current', a scene inside the project " +
238
+ "(res://, or an absolute path inside the root), or a uid:// reference. " +
239
+ "Refuses a scene outside the project root, one that does not exist, and an empty scene: measured on 4.7, " +
240
+ "the adapter ACCEPTS all of those and nothing runs โ€” an out-of-project scene leaves a live sceneless game " +
241
+ "and a session that reports 'running' forever. A uid:// the project does not know is the one case still " +
242
+ "not caught: Godot silently runs the main scene instead, and resolving it needs the engine's UID map. " +
59
243
  "Any breakpoints set beforehand are applied during the handshake. " +
244
+ "Refuses if the debug adapter rejects the launch (e.g. Godot answers 'wrong_path' when the configured " +
245
+ "project path is not the one the editor has open) rather than reporting a session that never started. " +
246
+ "With stop_on_entry the result carries stop_on_entry_honored: Godot's adapter does not implement " +
247
+ "stopOnEntry, so it reports false plus a warning instead of a bare 'running'. " +
60
248
  "Refuses if the runtime bridge port is already bound โ€” the new game could not host the bridge, so " +
61
249
  "runtime_* would address the process already holding the port. Clear the holder (godot_stop, or quit " +
62
250
  "it), or dbg_attach onto it if it is already under the debugger, or pass allow_port_conflict " +
63
251
  "(dbg_* is unaffected either way; only runtime_* is).",
64
252
  inputSchema: {
65
- scene: z.string().optional().describe("'main' (default), 'current', or res://scene.tscn"),
253
+ scene: z
254
+ .string()
255
+ .optional()
256
+ .describe("'main' (default), 'current', res://scene.tscn, or uid://โ€ฆ โ€” must be inside the project"),
66
257
  stop_on_entry: z.boolean().optional().describe("Break at entry (default false)"),
67
258
  allow_port_conflict: z
68
259
  .boolean()
@@ -72,6 +263,13 @@ export function registerDapTools(server, dap, cfg) {
72
263
  "tools would talk to the process holding the port, not to this game."),
73
264
  },
74
265
  }, async ({ scene, stop_on_entry, allow_port_conflict }) => {
266
+ // Before the port check and before the transport โ€” see guardScene.
267
+ try {
268
+ guardScene(scene);
269
+ }
270
+ catch (err) {
271
+ return fail(err);
272
+ }
75
273
  if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
76
274
  return {
77
275
  isError: true,
@@ -79,12 +277,23 @@ export function registerDapTools(server, dap, cfg) {
79
277
  };
80
278
  }
81
279
  try {
82
- await dap.start("launch", {
83
- project: cfg.projectPath,
84
- scene: scene ?? "main",
85
- stopOnEntry: stop_on_entry ?? false,
86
- });
87
- return ok({ session_id: "godot", state: dap.state, scene: scene ?? "main" });
280
+ const { entryStopSeen } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0);
281
+ const result = { session_id: "godot", state: dap.state, scene: scene ?? "main" };
282
+ if (stop_on_entry) {
283
+ // ๐Ÿ”ด Measured on Godot 4.7: stopOnEntry is ignored outright โ€” the game ran to
284
+ // completion and printed its _ready() line while the tool answered a bare
285
+ // `running`, which reads exactly like a launch that simply had not stopped
286
+ // YET. Say which one it is.
287
+ result.stop_on_entry_honored = entryStopSeen;
288
+ if (!entryStopSeen) {
289
+ result.warning =
290
+ `The connected Godot debug adapter did not stop at entry within ${ENTRY_STOP_WAIT_MS}ms ` +
291
+ `and the program is running. Godot's adapter does not implement stopOnEntry โ€” set a ` +
292
+ `breakpoint with dbg_set_breakpoints before dbg_launch to land a stop.`;
293
+ }
294
+ }
295
+ reportDroppedModifiers(result);
296
+ return ok(result);
88
297
  }
89
298
  catch (err) {
90
299
  return fail(err);
@@ -95,7 +304,9 @@ export function registerDapTools(server, dap, cfg) {
95
304
  // exit and leave the caller with nowhere to go.
96
305
  server.registerTool("dbg_attach", {
97
306
  title: "Attach debug session",
98
- description: "Attach to an already-running Godot debug session.",
307
+ description: "Attach to an already-running Godot debug session. Refuses if the adapter rejects the attach โ€” " +
308
+ "Godot answers 'not_running' when nothing is running โ€” instead of reporting state 'running' for a " +
309
+ "session that never started.",
99
310
  inputSchema: {
100
311
  address: z.string().optional().describe("Address of the running game (default 127.0.0.1)"),
101
312
  port: z.number().int().optional().describe("Remote debug port"),
@@ -103,7 +314,9 @@ export function registerDapTools(server, dap, cfg) {
103
314
  }, async ({ address, port }) => {
104
315
  try {
105
316
  await dap.start("attach", { address: address ?? "127.0.0.1", port });
106
- return ok({ session_id: "godot", state: dap.state });
317
+ const result = { session_id: "godot", state: dap.state };
318
+ reportDroppedModifiers(result);
319
+ return ok(result);
107
320
  }
108
321
  catch (err) {
109
322
  return fail(err);
@@ -112,10 +325,15 @@ export function registerDapTools(server, dap, cfg) {
112
325
  server.registerTool("dbg_set_breakpoints", {
113
326
  title: "Set breakpoints",
114
327
  description: "Set (replace) the breakpoints for a source file. Applied immediately if a session is running, else buffered until launch. " +
328
+ "Refuses a source that can never bind: a missing file, a directory, an empty path (which resolves to the project root), " +
329
+ "or a path resolving outside the Godot project root โ€” Godot binds breakpoints only to scripts in the project it runs, so " +
330
+ "unlike cs_dbg_set_breakpoints an absolute path outside the root is refused here too. " +
115
331
  "Feature-detected: the per-line conditions / hit_conditions / log_messages modifiers are only sent when the connected adapter " +
116
332
  "advertises support (supportsConditionalBreakpoints / supportsHitConditionalBreakpoints / supportsLogPoints). On an adapter that " +
117
- "advertises them unsupported (e.g. Godot 4.3, which ignores them and would otherwise halt unconditionally) the modifier is dropped " +
118
- "and the result includes `unsupported_modifiers` plus a `warning`. Detection needs a live session, so set modifiers after dbg_launch.",
333
+ "advertises them unsupported (Godot advertises all three false and IGNORES them, so the breakpoint would halt unconditionally) the " +
334
+ "modifier is dropped and the result includes `unsupported_modifiers` plus a `warning`. Detection happens when the breakpoints are " +
335
+ "applied, so it covers buffered ones too: setting modifiers before a session exists returns `modifier_detection: \"deferred\"` with a " +
336
+ "warning, and dbg_launch then reports the `unsupported_modifiers` actually dropped during the handshake.",
119
337
  inputSchema: {
120
338
  path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
121
339
  lines: z.array(z.number().int().positive()).describe("1-based line numbers"),
@@ -125,28 +343,29 @@ export function registerDapTools(server, dap, cfg) {
125
343
  },
126
344
  }, async ({ path, lines, conditions, hit_conditions, log_messages }) => {
127
345
  try {
128
- const fsPath = toFsPath(path, cfg.projectPath);
129
- // Feature-detect the per-line modifiers against the connected adapter. When it does
130
- // not advertise support (Godot 4.3 advertises none AND ignores them, so the
131
- // breakpoint would halt unconditionally), DROP the field and warn rather than send
132
- // something the adapter mishandles.
133
- const dropped = unsupportedBreakpointModifiers(dap.capabilities, {
134
- condition: hasModifier(conditions),
135
- hitCondition: hasModifier(hit_conditions),
136
- logMessage: hasModifier(log_messages),
137
- });
138
- const drop = new Set(dropped);
139
- const body = await dap.setBreakpoints(fsPath, lines, drop.has("condition") ? undefined : conditions, drop.has("hitCondition") ? undefined : hit_conditions, drop.has("logMessage") ? undefined : log_messages);
346
+ const fsPath = guardSource(path);
347
+ // The client feature-detects and drops at apply time โ€” on this call when a session
348
+ // is live, during the handshake when the breakpoint is buffered.
349
+ const before = new Set(dap.droppedBreakpointModifiers());
350
+ const body = await dap.setBreakpoints(fsPath, lines, conditions, hit_conditions, log_messages);
140
351
  const verified = Array.isArray(body["breakpoints"])
141
352
  ? body["breakpoints"].map((b) => ({ line: b.line ?? 0, verified: Boolean(b.verified) }))
142
353
  : [];
143
354
  const result = { path: fsPath, buffered: body["buffered"] === true, breakpoints: verified };
355
+ const dropped = dap.droppedBreakpointModifiers().filter((d) => !before.has(d));
144
356
  if (dropped.length) {
145
357
  result.unsupported_modifiers = dropped;
358
+ result.warning = warnDropped(dropped);
359
+ }
360
+ else if (body["modifier_detection_deferred"] === true) {
361
+ // ๐Ÿ”ด Buffered with modifiers and no session yet: the honest answer is "not yet
362
+ // known", never silence. dbg_launch reports what was actually dropped.
363
+ result.modifier_detection = "deferred";
146
364
  result.warning =
147
- `The connected Godot debug adapter does not support ${dropped.join(", ")} on breakpoints (it advertises ` +
148
- `${dropped.length > 1 ? "them" : "it"} unsupported), so ${dropped.length > 1 ? "they were" : "it was"} dropped โ€” ` +
149
- `the affected breakpoint(s) will halt unconditionally.`;
365
+ "These breakpoints are buffered until launch, so the adapter has not advertised its capabilities yet and the " +
366
+ "conditions / hit_conditions / log_messages modifiers COULD NOT be feature-detected. Godot ignores modifiers it " +
367
+ "does not support, which makes such a breakpoint halt unconditionally. dbg_launch reports `unsupported_modifiers` " +
368
+ "for whatever is dropped when these are applied.";
150
369
  }
151
370
  return ok(result);
152
371
  }
@@ -161,6 +380,7 @@ export function registerDapTools(server, dap, cfg) {
161
380
  inputSchema: {},
162
381
  }, async () => {
163
382
  try {
383
+ requireSession("dbg_continue");
164
384
  const r = await dap.resume("continue", { threadId: dap.threadId() }, RESUME_WAIT_MS);
165
385
  return ok({ state: r.state, stopped_reason: r.reason });
166
386
  }
@@ -175,6 +395,7 @@ export function registerDapTools(server, dap, cfg) {
175
395
  inputSchema: { kind: z.enum(["in", "over", "out"]).describe("Step kind") },
176
396
  }, async ({ kind }) => {
177
397
  try {
398
+ requireSession("dbg_step");
178
399
  const command = kind === "in" ? "stepIn" : kind === "out" ? "stepOut" : "next";
179
400
  const r = await dap.resume(command, { threadId: dap.threadId() }, RESUME_WAIT_MS);
180
401
  return ok({ state: r.state, stopped_reason: r.reason });
@@ -189,6 +410,7 @@ export function registerDapTools(server, dap, cfg) {
189
410
  inputSchema: { levels: z.number().int().positive().optional().describe("Max frames (default 20)") },
190
411
  }, async ({ levels }) => {
191
412
  try {
413
+ requireSession("dbg_stack_trace");
192
414
  const body = await dap.request("stackTrace", { threadId: dap.threadId(), startFrame: 0, levels: levels ?? 20 });
193
415
  const frames = Array.isArray(body["stackFrames"])
194
416
  ? body["stackFrames"].map((f) => ({
@@ -207,6 +429,7 @@ export function registerDapTools(server, dap, cfg) {
207
429
  inputSchema: { frame_id: z.number().int().describe("Frame id from dbg_stack_trace") },
208
430
  }, async ({ frame_id }) => {
209
431
  try {
432
+ requireSession("dbg_scopes");
210
433
  const body = await dap.request("scopes", { frameId: frame_id });
211
434
  const scopes = Array.isArray(body["scopes"])
212
435
  ? body["scopes"].map((s) => ({ name: s.name ?? "", variables_ref: s.variablesReference ?? 0 }))
@@ -223,6 +446,7 @@ export function registerDapTools(server, dap, cfg) {
223
446
  inputSchema: { variables_ref: z.number().int().describe("variablesReference from dbg_scopes or a parent variable") },
224
447
  }, async ({ variables_ref }) => {
225
448
  try {
449
+ requireSession("dbg_variables");
226
450
  const body = await dap.request("variables", { variablesReference: variables_ref });
227
451
  const variables = Array.isArray(body["variables"])
228
452
  ? body["variables"].map((v) => ({
@@ -245,6 +469,9 @@ export function registerDapTools(server, dap, cfg) {
245
469
  },
246
470
  }, async ({ expression, frame_id, confirm }) => {
247
471
  try {
472
+ // Before the gate on purpose: the old order prompted the operator to approve
473
+ // arbitrary code execution against a session that had never been started.
474
+ requireSession("dbg_evaluate");
248
475
  const blocked = await gate(server, confirm, `Evaluate expression in the running game: ${expression}`);
249
476
  if (blocked)
250
477
  return blocked;
@@ -285,6 +512,7 @@ export function registerDapTools(server, dap, cfg) {
285
512
  },
286
513
  }, async ({ add, remove, clear, frame_id }) => {
287
514
  try {
515
+ requireSession("dbg_watch");
288
516
  if (clear)
289
517
  dap.clearWatches();
290
518
  if (remove && remove.length)
@@ -352,6 +580,8 @@ export function registerDapTools(server, dap, cfg) {
352
580
  },
353
581
  }, async ({ variables_ref, name, value, confirm }) => {
354
582
  try {
583
+ // Before the gate and the caps check on purpose โ€” see dbg_evaluate.
584
+ requireSession("dbg_set_variable");
355
585
  // Feature-detect: some debug adapters don't implement setVariable. If the
356
586
  // adapter explicitly advertised it as unsupported, say so plainly instead
357
587
  // of prompting for a confirmation and then failing.
@@ -400,13 +630,25 @@ export function registerDapTools(server, dap, cfg) {
400
630
  description: "Restart the current debug session. Uses the DAP `restart` request when the adapter advertises `supportsRestartRequest`, " +
401
631
  "otherwise falls back to terminate + relaunch โ€” so it works on every adapter. Reuses the last dbg_launch/dbg_attach parameters; " +
402
632
  "pass `scene` / `stop_on_entry` to override them for a launched session. `method` in the result reports which path ran " +
403
- "('restart' = native DAP restart, 'relaunch' = terminate + fresh handshake). Requires a session started with dbg_launch/dbg_attach.",
633
+ "('restart' = native DAP restart, 'relaunch' = terminate + fresh handshake). Requires a session started with dbg_launch/dbg_attach. " +
634
+ "`scene` is held to the same rule as dbg_launch's โ€” inside the project root, existing, non-empty.",
404
635
  inputSchema: {
405
- scene: z.string().optional().describe("Override the scene for a launched session: 'main', 'current', or res://scene.tscn"),
636
+ scene: z
637
+ .string()
638
+ .optional()
639
+ .describe("Override the scene for a launched session: 'main', 'current', res://scene.tscn, or uid://โ€ฆ"),
406
640
  stop_on_entry: z.boolean().optional().describe("Override stop-at-entry for the restart (launched sessions)"),
407
641
  },
408
642
  }, async ({ scene, stop_on_entry }) => {
409
643
  try {
644
+ // ๐Ÿ”ด THE SECOND CALL SITE, and the reason it is here. 162 ยง8 item 5 named
645
+ // `dbg_launch` alone; `dbg_restart` takes the same `scene` and hands it to the
646
+ // same adapter, so guarding only the first would have left the plane guarded
647
+ // in name only. Measured before it was written: an unguarded `dbg_restart`
648
+ // onto `res://../example_evil/outside.tscn` answered
649
+ // `ok {"method":"restart","state":"running"}`, exactly like `dbg_launch`.
650
+ // ยง7's standing rule โ€” a guard is not wired until EVERY call site is wired.
651
+ guardScene(scene);
410
652
  const override = {};
411
653
  if (scene !== undefined)
412
654
  override.scene = scene;
@@ -425,6 +667,8 @@ export function registerDapTools(server, dap, cfg) {
425
667
  "list the valid goto targets on that line; when the line has exactly one target (or you pass `target_id`) it jumps there. " +
426
668
  "DESTRUCTIVE: skips or repeats code by moving execution โ€” confirm with the user and keep this gated. " +
427
669
  "Feature-detected: on an adapter that does not advertise `supportsGotoTargetsRequest` it returns a clear \"unsupported\" message WITHOUT prompting. " +
670
+ "Refuses a source that can never carry a target โ€” a missing file, a directory, an empty path (which resolves to the project root), " +
671
+ "or a path resolving outside the Godot project root โ€” the same guard dbg_set_breakpoints applies. " +
428
672
  "Only meaningful while stopped at a breakpoint.",
429
673
  inputSchema: {
430
674
  path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
@@ -440,7 +684,20 @@ export function registerDapTools(server, dap, cfg) {
440
684
  content: [{ type: "text", text: "dbg_goto is unsupported by the connected Godot build's debug adapter (it does not advertise supportsGotoTargetsRequest)." }],
441
685
  };
442
686
  }
443
- const fsPath = toFsPath(path, cfg.projectPath);
687
+ // ๐Ÿ”ด 162: THIS GUARD WAS MISSING, and only a measurement found it. 161 ยง8 item 5
688
+ // described dap.ts as carrying "its own inline copy of the escape guard" โ€” true
689
+ // of ONE of its two path-taking tools. `dbg_goto` resolved with a bare
690
+ // `toFsPath` and handed the result to the adapter as `source.path`: no
691
+ // containment check, no existence check. The same shape as 161 ยง4's "only
692
+ // board_create's stale-tab close was asserted, and one of four creators was
693
+ // covered" โ€” a guard is not wired until every call site is wired.
694
+ //
695
+ // ๐Ÿ”ด It is not currently REACHABLE on Godot: the capability gate above returns
696
+ // first because no Godot build advertises supportsGotoTargetsRequest. That is a
697
+ // capability, not a boundary โ€” it can change under us, and `dbg_goto` is the
698
+ // DESTRUCTIVE tool on this plane. Guarded on its own merits, not on the engine's
699
+ // current answer.
700
+ const fsPath = guardSource(path);
444
701
  const body = await dap.request("gotoTargets", { source: { path: fsPath }, line });
445
702
  const targets = Array.isArray(body["targets"])
446
703
  ? body["targets"].map((t) => ({ id: t.id ?? 0, label: t.label ?? "", line: t.line ?? 0 }))
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** AudioStreamPlayer nodes + audio bus layout / effects. */
4
- export function registerAudioTools(server, call) {
4
+ export function registerAudioTools(server, call, guard) {
5
5
  server.registerTool("audio_player_create", {
6
6
  title: "Create audio player",
7
7
  description: "Add an AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D node under a parent in the edited scene (undoable). `dim` selects \"none\" (default, non-positional AudioStreamPlayer), \"2d\", or \"3d\". Optional initial stream_path (res:// AudioStream), autoplay, volume_db, bus. In-scene and undoable โ€” not gated.",
@@ -15,6 +15,9 @@ export function registerAudioTools(server, call) {
15
15
  bus: z.string().optional().describe("Target audio bus name (default \"Master\")"),
16
16
  },
17
17
  }, async ({ parent_path, dim, name, stream_path, autoplay, volume_db, bus }) => {
18
+ const escaped = guard(stream_path, "stream_path");
19
+ if (escaped)
20
+ return escaped;
18
21
  const params = { parent_path };
19
22
  if (dim !== undefined)
20
23
  params.dim = dim;
@@ -37,7 +40,12 @@ export function registerAudioTools(server, call) {
37
40
  path: z.string().describe("AudioStreamPlayer/2D/3D node path relative to the scene root"),
38
41
  stream_path: z.string().describe("res:// path to an AudioStream resource"),
39
42
  },
40
- }, async ({ path, stream_path }) => call("audio.set_stream", { path, stream_path }));
43
+ }, async ({ path, stream_path }) => {
44
+ const escaped = guard(stream_path, "stream_path");
45
+ if (escaped)
46
+ return escaped;
47
+ return call("audio.set_stream", { path, stream_path });
48
+ });
41
49
  server.registerTool("audio_bus_add", {
42
50
  title: "Add audio bus",
43
51
  description: "Add a bus to the project's global AudioServer layout, optionally naming it, positioning it (at_position), and setting its send bus. DESTRUCTIVE (project-wide audio state) โ€” gated by confirmation.",
@@ -100,6 +108,12 @@ export function registerAudioTools(server, call) {
100
108
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
101
109
  },
102
110
  }, async ({ to_path, confirm }) => {
111
+ // `to_path` is OPTIONAL here โ€” an omitted one takes the addon's documented
112
+ // default and has nothing to escape, which is why the guard passes `undefined`
113
+ // through untouched rather than refusing it.
114
+ const escaped = guard(to_path, "to_path");
115
+ if (escaped)
116
+ return escaped;
103
117
  const blocked = await gate(server, confirm, `Save audio bus layout to ${to_path ?? "res://default_bus_layout.tres"}`);
104
118
  if (blocked)
105
119
  return blocked;
@@ -1,4 +1,5 @@
1
- import { ok } from "../lsp-common.js";
1
+ import { ok, failPath } from "../lsp-common.js";
2
+ import { resolveInsideProject } from "../../paths.js";
2
3
  /**
3
4
  * MCP error envelope for a failed editor-bridge call (never throws to the
4
5
  * caller). Distinct from lsp-common's `fail` (which labels errors "LSP error");
@@ -28,4 +29,41 @@ export function makeCall(bridge) {
28
29
  }
29
30
  };
30
31
  }
32
+ // ------------------------------------------------------------ path guard ----
33
+ //
34
+ // MEASURED, session 164, against a real 4.7 editor with the addon live, on a temp
35
+ // project copy at /private/tmp/g164 with a prefix-sharing sibling `example_evil/`:
36
+ // ELEVEN editor writers created files OUTSIDE the project root through `res://../`,
37
+ // and `filesystem_move` MOVED one out. Every one answered `ok` and echoed the
38
+ // escaping path straight back. The verdict came from `stat`, not from the reply โ€”
39
+ // 163 ยง1's lesson, which is that an ACCEPTANCE is only measured by asking a channel
40
+ // the tool does not control.
41
+ //
42
+ // ๐Ÿ”ด THE ADDON'S ONLY CHECK IS `to_path.begins_with("res://")`, at seventeen call
43
+ // sites in operations.gd, and `res://../` satisfies it. That is why a bare relative
44
+ // and an absolute path are already refused (self-announcing) while `res://../` was
45
+ // silent. This guard closes the silent one; it does not touch the other two.
46
+ /**
47
+ * Build the containment guard the editor writers share. Returns `null` when the
48
+ * path is legal and a ready-to-return error envelope when it escapes, so a call
49
+ * site reads as one line and cannot forget the `return`.
50
+ *
51
+ * ๐Ÿ”ด IT RETURNS NOTHING AND REWRITES NOTHING. The caller's ORIGINAL spelling still
52
+ * goes on the wire, so `res://foo.tres` reaches the addon exactly as before and the
53
+ * guard is provably inert on every path that already worked โ€” the same property
54
+ * 163's `guardScene` was built for, and an over-eager mutation keeps it that way.
55
+ */
56
+ export function makePathGuard(projectPath) {
57
+ return (p, label) => {
58
+ if (p === undefined)
59
+ return null; // an absent optional param has nothing to escape
60
+ try {
61
+ resolveInsideProject(p, projectPath, label);
62
+ return null;
63
+ }
64
+ catch (err) {
65
+ return failPath(err);
66
+ }
67
+ };
68
+ }
31
69
  //# sourceMappingURL=common.js.map