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
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../../confirm.js";
3
3
  /** UI / Control / theming. control_* + container_add_child mutate the EDITED scene and are undoable via EditorUndoRedoManager and ungated (the node_* model). theme_* author a Theme on disk via ResourceSaver, so — like resource_* — they are file-writers gated by confirmation. */
4
- export function registerUiTools(server, call) {
4
+ export function registerUiTools(server, call, guard) {
5
5
  server.registerTool("control_create", {
6
6
  title: "Create control",
7
7
  description: "Instance a Control-derived UI node (Button, Label, Panel, VBoxContainer, TextureRect, …) under a parent (undoable). Refuses non-Control classes. Optional 'text' is applied only to controls that expose a 'text' property. Returns the new node's path.",
@@ -93,7 +93,15 @@ export function registerUiTools(server, call) {
93
93
  path: z.string().describe("Control node path relative to the scene root"),
94
94
  theme_path: z.string().describe("Theme res:// path, or \"\" to clear the theme override"),
95
95
  },
96
- }, async ({ path, theme_path }) => call("control.set_theme", { path, theme_path }));
96
+ },
97
+ // `""` is the documented "clear the override" spelling and resolves to the
98
+ // project root itself, which is INSIDE it — so the guard leaves it alone.
99
+ async ({ path, theme_path }) => {
100
+ const escaped = guard(theme_path, "theme_path");
101
+ if (escaped)
102
+ return escaped;
103
+ return call("control.set_theme", { path, theme_path });
104
+ });
97
105
  server.registerTool("theme_create", {
98
106
  title: "Create theme",
99
107
  description: "Create a new empty Theme resource and save it to a res:// path. DESTRUCTIVE (writes a file) — gated by confirmation.",
@@ -102,6 +110,9 @@ export function registerUiTools(server, call) {
102
110
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
103
111
  },
104
112
  }, async ({ to_path, confirm }) => {
113
+ const escaped = guard(to_path, "to_path");
114
+ if (escaped)
115
+ return escaped;
105
116
  const blocked = await gate(server, confirm, `Create Theme resource at ${to_path}`);
106
117
  if (blocked)
107
118
  return blocked;
@@ -118,6 +129,13 @@ export function registerUiTools(server, call) {
118
129
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
119
130
  },
120
131
  }, async ({ path, name, theme_type, color, confirm }) => {
132
+ // 🔴 THE THEME ITSELF IS LOADED AND RE-SAVED, so `path` is a WRITE target. All four
133
+ // theme_set_* tools rewrote a Theme OUTSIDE the project root through ALL THREE
134
+ // spellings — verdict from the file's bytes. `theme_set_font`/`theme_set_stylebox`
135
+ // guarded their SECOND parameter and left this one open since #175.
136
+ const escaped = guard(path, "path");
137
+ if (escaped)
138
+ return escaped;
121
139
  const blocked = await gate(server, confirm, `Set theme color ${theme_type}/${name} in ${path}`);
122
140
  if (blocked)
123
141
  return blocked;
@@ -134,6 +152,9 @@ export function registerUiTools(server, call) {
134
152
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
135
153
  },
136
154
  }, async ({ path, name, theme_type, font_path, confirm }) => {
155
+ const escaped = guard(path, "path") ?? guard(font_path, "font_path");
156
+ if (escaped)
157
+ return escaped;
137
158
  const blocked = await gate(server, confirm, `Set theme font ${theme_type}/${name} in ${path}`);
138
159
  if (blocked)
139
160
  return blocked;
@@ -150,6 +171,9 @@ export function registerUiTools(server, call) {
150
171
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
151
172
  },
152
173
  }, async ({ path, name, theme_type, stylebox_path, confirm }) => {
174
+ const escaped = guard(path, "path") ?? guard(stylebox_path, "stylebox_path");
175
+ if (escaped)
176
+ return escaped;
153
177
  const blocked = await gate(server, confirm, `Set theme stylebox ${theme_type}/${name} in ${path}`);
154
178
  if (blocked)
155
179
  return blocked;
@@ -166,6 +190,9 @@ export function registerUiTools(server, call) {
166
190
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
167
191
  },
168
192
  }, async ({ path, name, theme_type, value, confirm }) => {
193
+ const escaped = guard(path, "path");
194
+ if (escaped)
195
+ return escaped;
169
196
  const blocked = await gate(server, confirm, `Set theme constant ${theme_type}/${name} in ${path}`);
170
197
  if (blocked)
171
198
  return blocked;
@@ -1,4 +1,4 @@
1
- import { makeCall } from "./editor/common.js";
1
+ import { makeCall, makePathGuard } from "./editor/common.js";
2
2
  import { registerCoreTools } from "./editor/core.js";
3
3
  import { registerSceneTools } from "./editor/scene.js";
4
4
  import { registerNodeTools } from "./editor/node.js";
@@ -22,23 +22,30 @@ import { registerProjectInputTestTools } from "./editor/project_input_test.js";
22
22
  * helper and registers each group in its original order, so the registered tool
23
23
  * set and its order are unchanged.
24
24
  */
25
- export function registerEditorTools(server, bridge) {
25
+ export function registerEditorTools(server, bridge, config) {
26
26
  const call = makeCall(bridge);
27
+ // 🔴 THE GUARD IS THREADED, NOT INSTALLED IN `makeCall`. Wrapping the shared bridge
28
+ // helper would have been one line, but it serves ~150 editor tools whose params are
29
+ // node paths, property names and class names — a helper that has to GUESS which
30
+ // string is a filesystem path is a guess on every one of them. The writers (164)
31
+ // and READERS (165) that were MEASURED escaping take it explicitly instead, so the
32
+ // blast radius of this change is exactly the set that was measured.
33
+ const guard = makePathGuard(config.projectPath);
27
34
  registerCoreTools(server, call);
28
- registerSceneTools(server, call);
29
- registerNodeTools(server, call);
35
+ registerSceneTools(server, call, guard);
36
+ registerNodeTools(server, call, guard);
30
37
  registerSignalTools(server, call);
31
38
  registerIntrospectionTools(server, call, bridge);
32
- registerResourceTools(server, call);
33
- registerFilesystemTools(server, call);
39
+ registerResourceTools(server, call, guard);
40
+ registerFilesystemTools(server, call, guard);
34
41
  registerAnimationTools(server, call);
35
- registerTileTools(server, call);
42
+ registerTileTools(server, call, guard);
36
43
  registerPhysicsTools(server, call);
37
- registerParticleTools(server, call);
38
- registerShaderTools(server, call);
39
- registerAudioTools(server, call);
40
- registerUiTools(server, call);
41
- registerSpatialTools(server, call);
42
- registerProjectInputTestTools(server, call);
44
+ registerParticleTools(server, call, guard);
45
+ registerShaderTools(server, call, guard);
46
+ registerAudioTools(server, call, guard);
47
+ registerUiTools(server, call, guard);
48
+ registerSpatialTools(server, call, guard);
49
+ registerProjectInputTestTools(server, call, guard);
43
50
  }
44
51
  //# sourceMappingURL=editor.js.map
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
- import { toFsPath } from "../paths.js";
4
+ import { resolveInsideProject, toFsPath } from "../paths.js";
5
+ import { failPath } from "./lsp-common.js";
5
6
  /**
6
7
  * Group K — Knowledge & search (read-only).
7
8
  *
@@ -351,6 +352,18 @@ export function registerKnowledgeTools(server, cfg) {
351
352
  max_results: z.number().int().positive().optional().describe("Cap on returned matches (default 200)"),
352
353
  },
353
354
  }, async ({ query, regex, ignore_case, extensions, path: sub, max_results }) => {
355
+ // 🔴 MEASURED: `path: "res://../example_evil"` SEARCHED AND RETURNED MATCHES from
356
+ // files outside the project root. This tool never touches the editor bridge, so
357
+ // the addon's res:// check was never in front of it — the host is the only guard.
358
+ // An omitted `path` means "whole project" and stays legal.
359
+ if (sub !== undefined) {
360
+ try {
361
+ resolveInsideProject(sub, cfg.projectPath, "path");
362
+ }
363
+ catch (err) {
364
+ return failPath(err);
365
+ }
366
+ }
354
367
  const cap = max_results ?? 200;
355
368
  let re;
356
369
  try {
@@ -28,11 +28,40 @@ export function ok(obj) {
28
28
  /** MCP error envelope for a failed LSP call (never throws to the caller). */
29
29
  export function fail(err) {
30
30
  const e = err;
31
+ // A REFUSAL is the host declining the call, not the language server failing
32
+ // one. Rendering it as "LSP error [...]" sent the caller to debug a server
33
+ // that was never asked. Refusals carry their own message verbatim.
34
+ if (e?.refusal) {
35
+ return {
36
+ isError: true,
37
+ content: [{ type: "text", text: e.message ?? String(err) }],
38
+ };
39
+ }
31
40
  return {
32
41
  isError: true,
33
42
  content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}` }],
34
43
  };
35
44
  }
45
+ /**
46
+ * MCP error envelope for a PATH REFUSAL — the host declining a call before it
47
+ * touches any backend. Lives here, beside `ok`/`fail`, because it belongs to no
48
+ * single plane: the editor writers and the asset generators both raise it.
49
+ *
50
+ * 🔴 DELIBERATELY NOT the editor plane's `fail()`, which labels everything
51
+ * "Bridge error" — that means "the editor could not be reached" and sends the
52
+ * caller to restart Godot over a typo in their own path. The distinction is the
53
+ * one `fail()` above already draws for LSP refusals, applied to the writers.
54
+ *
55
+ * The refusal's own message and `path_outside_project` code carry through
56
+ * unchanged, which is what every gate in the tree actually pins.
57
+ */
58
+ export function failPath(err) {
59
+ const be = err;
60
+ return {
61
+ isError: true,
62
+ content: [{ type: "text", text: `Path error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}` }],
63
+ };
64
+ }
36
65
  /**
37
66
  * Normalize an LSP documentation / MarkupContent field (a plain string, a
38
67
  * `{ kind, value }` MarkupContent, or an array of either) down to a single
package/dist/tools/lsp.js CHANGED
@@ -1,8 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import { z } from "zod";
3
- import { toFileUri, toFsPath, readFileText } from "../paths.js";
3
+ import { toFileUri, toFsPath, readFileText, resolveSourceFile } from "../paths.js";
4
4
  import { gate } from "../confirm.js";
5
5
  import { COMPLETION_KIND, SYMBOL_KIND, ok, fail, markupToString, isMethodNotFound, normalizeLocations, applyTextEdits, } from "./lsp-common.js";
6
+ /**
7
+ * The wording this plane's refusals have shipped with, kept verbatim while the guard
8
+ * itself moved to `paths.ts` (161 §8 item 5). `lsp-plane.integration.mjs` pins every
9
+ * string here by regex — /outside the Godot project root/, /no such file/,
10
+ * /is not a file/ — so this constant IS the contract, not decoration.
11
+ *
12
+ * Measured, sessions 156 and 162: `toFsPath` joins through `path.join`, which
13
+ * normalizes `..` away silently, so `res://../../../etc/passwd` resolved to a real
14
+ * path outside the project and the tools answered success for it. And a missing file
15
+ * was OPENED AS AN EMPTY ONE: `gd_document_symbols` returned a phantom
16
+ * `{name: "<file>.gd", kind: "class"}` and `gd_diagnostics` a genuine
17
+ * "(EMPTY_FILE): Empty script file." warning — both isError:false, about a file that
18
+ * is not there.
19
+ *
20
+ * 🔴 162: this guard sits AFTER each tool's capability check, so on Godot 4.7 only
21
+ * 11 of the 19 path-taking gd_* tools reach it — the other 8 answer "unsupported"
22
+ * first. Nothing escapes (an unsupported tool never touches the path), but the live
23
+ * gate can only assert the refusal on tools whose provider the connected build
24
+ * advertises. Measured, recorded, deliberately NOT rearranged.
25
+ */
26
+ const GD_LSP_PATHS = {
27
+ root: "the Godot project root",
28
+ escapeHint: "Pass a res:// path, or a path inside the project.",
29
+ missingHint: "It was previously opened as an EMPTY document, so the language server answered " +
30
+ "about a file that does not exist.",
31
+ };
6
32
  /**
7
33
  * Returned by gd_workspace_symbols when the connected Godot build's GDScript
8
34
  * language server has no `workspace/symbol` method. This is an engine limitation
@@ -165,16 +191,52 @@ function decodeSemanticTokens(data, legend) {
165
191
  }
166
192
  // offsetOf / applyTextEdits now live in ./lsp-common.js (shared with the C#
167
193
  // rename mutator, cs_rename). Imported above.
194
+ /**
195
+ * A GDScript identifier: the only thing `gd_rename` may rename a symbol TO.
196
+ * Measured on real 4.3/4.5/4.7 — the language server happily plans a full
197
+ * project-wide rename to "", "1bad name!", "func" or a string containing a
198
+ * newline and answers `edit_count: 9` for every one of them. With `apply:true`
199
+ * those edits WRITE GDScript that cannot parse, so the refusal has to happen
200
+ * here: the server will not do it for us.
201
+ */
202
+ const GD_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
203
+ /**
204
+ * GDScript reserved words. A rename to one of these is syntactically an
205
+ * identifier but produces a file the parser rejects, so it is refused for the
206
+ * same reason. Kept deliberately narrow — engine CLASS names (Node, Vector2)
207
+ * are shadowable and legal, so they are NOT listed.
208
+ */
209
+ const GD_KEYWORDS = new Set([
210
+ "if", "elif", "else", "for", "while", "match", "when", "break", "continue", "pass", "return",
211
+ "class", "class_name", "extends", "is", "in", "as", "self", "super", "signal", "func",
212
+ "static", "const", "enum", "var", "breakpoint", "preload", "await", "yield", "assert",
213
+ "void", "PI", "TAU", "INF", "NAN", "and", "or", "not", "true", "false", "null",
214
+ ]);
168
215
  export function registerLspTools(server, lsp, cfg) {
216
+ /**
217
+ * Resolve a caller-supplied script path and REFUSE one that escapes the
218
+ * project root. `toFsPath` joins through `path.join`, which normalizes `..`
219
+ * away silently — so `res://../../../etc/passwd` resolved to a real path
220
+ * outside the project and the tools answered success for it. Measured: the
221
+ * answer was the same degenerate one any missing path produces, so nothing
222
+ * leaked, but nothing refused it either.
223
+ */
224
+ const guardPath = (p) => {
225
+ resolveSourceFile(p, cfg.projectPath, GD_LSP_PATHS);
226
+ };
169
227
  const openAndPos = async (path) => {
228
+ guardPath(path);
170
229
  const uri = toFileUri(path, cfg.projectPath);
171
230
  await lsp.ensureOpen(uri, readFileText(toFsPath(path, cfg.projectPath)));
172
231
  return uri;
173
232
  };
174
233
  const posSchema = {
175
234
  path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
176
- line: z.number().int().describe("0-based line"),
177
- character: z.number().int().describe("0-based character"),
235
+ // .min(0): a negative line or character is not a position. Measured — they
236
+ // sailed through to the wire and the server answered success with empty
237
+ // contents, so the caller could not tell a bad request from a real miss.
238
+ line: z.number().int().min(0).describe("0-based line"),
239
+ character: z.number().int().min(0).describe("0-based character"),
178
240
  };
179
241
  server.registerTool("gd_completion", { title: "GDScript completion", description: "Type-aware code completion at a position via the Godot language server.", inputSchema: posSchema }, async ({ path, line, character }) => {
180
242
  try {
@@ -238,7 +300,8 @@ export function registerLspTools(server, lsp, cfg) {
238
300
  });
239
301
  server.registerTool("gd_rename", {
240
302
  title: "GDScript rename symbol",
241
- description: "Rename a symbol project-wide. Returns the planned edit; pass apply=true to WRITE the changes to disk (DESTRUCTIVE — confirm with the user).",
303
+ description: "Rename a symbol project-wide. Returns the planned edit; pass apply=true to WRITE the changes to disk (DESTRUCTIVE — confirm with the user). " +
304
+ "new_name must be a valid GDScript identifier and not a reserved word: the language server plans a rename to any string, including one that would write a file GDScript cannot parse, so an illegal name is refused here before the rename is planned.",
242
305
  inputSchema: {
243
306
  ...posSchema,
244
307
  new_name: z.string().describe("New symbol name"),
@@ -247,6 +310,21 @@ export function registerLspTools(server, lsp, cfg) {
247
310
  },
248
311
  }, async ({ path, line, character, new_name, apply, confirm }) => {
249
312
  try {
313
+ // Refuse BEFORE planning. The language server does not validate the new
314
+ // name: measured on 4.3/4.5/4.7, a rename to "", "1bad name!", "func"
315
+ // or "a\nb" each came back with edit_count 9 and isError:false — and
316
+ // with apply:true those edits write a file GDScript cannot parse.
317
+ if (!GD_IDENTIFIER.test(new_name)) {
318
+ throw Object.assign(new Error(`Refusing to rename to ${JSON.stringify(new_name)}: it is not a valid GDScript ` +
319
+ `identifier (letter or underscore, then letters, digits or underscores). ` +
320
+ `The language server will plan this rename anyway and the written file would ` +
321
+ `not parse.`), { refusal: true, code: "invalid_identifier" });
322
+ }
323
+ if (GD_KEYWORDS.has(new_name)) {
324
+ throw Object.assign(new Error(`Refusing to rename to "${new_name}": it is a GDScript reserved word. ` +
325
+ `The language server will plan this rename anyway and the written file would ` +
326
+ `not parse.`), { refusal: true, code: "reserved_word" });
327
+ }
250
328
  const uri = await openAndPos(path);
251
329
  const edit = (await lsp.request("textDocument/rename", {
252
330
  textDocument: { uri }, position: { line, character }, newName: new_name,
@@ -389,10 +467,10 @@ export function registerLspTools(server, lsp, cfg) {
389
467
  "end_line/end_character default to the start position (a caret, not a selection).",
390
468
  inputSchema: {
391
469
  path: z.string().describe("Script path (res://..., absolute, or project-relative)"),
392
- start_line: z.number().int().describe("0-based start line"),
393
- start_character: z.number().int().describe("0-based start character"),
394
- end_line: z.number().int().optional().describe("0-based end line (default = start_line)"),
395
- end_character: z.number().int().optional().describe("0-based end character (default = start_character)"),
470
+ start_line: z.number().int().min(0).describe("0-based start line"),
471
+ start_character: z.number().int().min(0).describe("0-based start character"),
472
+ end_line: z.number().int().min(0).optional().describe("0-based end line (default = start_line)"),
473
+ end_character: z.number().int().min(0).optional().describe("0-based end character (default = start_character)"),
396
474
  only: z.array(z.string()).optional().describe("Restrict to these CodeActionKind prefixes, e.g. 'quickfix', 'refactor', 'source'"),
397
475
  },
398
476
  }, async ({ path, start_line, start_character, end_line, end_character, only }) => {
@@ -579,6 +657,7 @@ export function registerLspTools(server, lsp, cfg) {
579
657
  const caps = await lsp.getServerCapabilities();
580
658
  if (!caps.documentFormattingProvider)
581
659
  return unsupportedLsp("gd_formatting", "textDocument/formatting", "documentFormattingProvider", alt);
660
+ guardPath(path);
582
661
  const fsPath = toFsPath(path, cfg.projectPath);
583
662
  const before = readFileText(fsPath);
584
663
  const uri = toFileUri(path, cfg.projectPath);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { gate } from "../confirm.js";
3
- import { toFsPath, readFileText } from "../paths.js";
3
+ import { readFileText, resolveExistingFile, resolveInsideProject } from "../paths.js";
4
4
  /**
5
5
  * Group M — Netcode & backend scaffolding (native multiplayer family, `mp_*`).
6
6
  *
@@ -202,7 +202,19 @@ export function registerNetcodeTools(server, bridge, config) {
202
202
  spawn_path: z.string().optional().describe("NodePath (relative to the spawner) whose children are auto-replicated"),
203
203
  spawnable_scenes: z.array(z.string()).optional().describe("res:// scenes the spawner may instantiate (add_spawnable_scene)"),
204
204
  },
205
- }, async ({ parent_path, name, spawn_path, spawnable_scenes }) => {
205
+ },
206
+ // 🔴 AN ARRAY PARAM, AND EVERY ELEMENT IS A PATH. The spawner STORED
207
+ // `res://../example_evil/x.tscn` on the node and echoed it back, so at runtime it
208
+ // would instantiate a scene from outside the project. Guard each element — a
209
+ // guard on `spawnable_scenes[0]` alone is not a guard.
210
+ async ({ parent_path, name, spawn_path, spawnable_scenes }) => {
211
+ try {
212
+ for (const s of spawnable_scenes ?? [])
213
+ resolveInsideProject(s, config.projectPath, "spawnable_scenes");
214
+ }
215
+ catch (err) {
216
+ return fail(err);
217
+ }
206
218
  try {
207
219
  return ok(await call("mp.add_spawner", {
208
220
  parent_path,
@@ -283,6 +295,13 @@ export function registerNetcodeTools(server, bridge, config) {
283
295
  }, async ({ to_path, port, max_clients, class_name, overwrite, confirm }) => {
284
296
  if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
285
297
  return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
298
+ // `res://../` satisfies the pre-guard and writes outside the root (161).
299
+ try {
300
+ resolveInsideProject(to_path, config.projectPath, "to_path");
301
+ }
302
+ catch (err) {
303
+ return fail(err);
304
+ }
286
305
  const blocked = await gate(server, confirm, `Write ENet peer script to ${to_path}`);
287
306
  if (blocked)
288
307
  return blocked;
@@ -310,6 +329,13 @@ export function registerNetcodeTools(server, bridge, config) {
310
329
  }, async ({ to_path, class_name, overwrite, confirm }) => {
311
330
  if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
312
331
  return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
332
+ // `res://../` satisfies the pre-guard and writes outside the root (161).
333
+ try {
334
+ resolveInsideProject(to_path, config.projectPath, "to_path");
335
+ }
336
+ catch (err) {
337
+ return fail(err);
338
+ }
313
339
  const blocked = await gate(server, confirm, `Write WebRTC peer script to ${to_path}`);
314
340
  if (blocked)
315
341
  return blocked;
@@ -348,9 +374,19 @@ export function registerNetcodeTools(server, bridge, config) {
348
374
  }, async ({ path, function: fn, mode, transfer_mode, call_local, channel, confirm }) => {
349
375
  if (!path.startsWith("res://") || !path.endsWith(".gd"))
350
376
  return fail({ code: "bad_params", message: "'path' must be a res:// .gd path" });
351
- const source = readFileText(toFsPath(path, config.projectPath));
377
+ // The res:// pre-guard above stops an absolute path and a bare `../`, but
378
+ // `res://../elsewhere/x.gd` satisfies startsWith("res://") and resolved to
379
+ // a real file outside the project root — which this tool then REWROTE.
380
+ // Measured 3/3 against a sibling directory (session 161).
381
+ let source;
382
+ try {
383
+ source = readFileText(resolveExistingFile(path, config.projectPath, "path"));
384
+ }
385
+ catch (err) {
386
+ return fail(err);
387
+ }
352
388
  if (source === "")
353
- return fail({ code: "not_found", message: `Cannot read ${path} (does it exist?)` });
389
+ return fail({ code: "empty_file", message: `${path} is empty (0 bytes) — there is no source to annotate.` });
354
390
  const annotation = rpcAnnotation({
355
391
  mode: mode ?? "authority",
356
392
  call_local: call_local ?? false,
@@ -395,6 +431,13 @@ export function registerNetcodeTools(server, bridge, config) {
395
431
  }, async ({ to_path, port, max_players, class_name, overwrite, confirm }) => {
396
432
  if (!to_path.startsWith("res://") || !to_path.endsWith(".gd"))
397
433
  return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
434
+ // `res://../` satisfies the pre-guard and writes outside the root (161).
435
+ try {
436
+ resolveInsideProject(to_path, config.projectPath, "to_path");
437
+ }
438
+ catch (err) {
439
+ return fail(err);
440
+ }
398
441
  const blocked = await gate(server, confirm, `Write multiplayer lobby script to ${to_path}`);
399
442
  if (blocked)
400
443
  return blocked;
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { z } from "zod";
3
3
  import { log } from "../logger.js";
4
- import { ok } from "./lsp-common.js";
4
+ import { ok, failPath } from "./lsp-common.js";
5
+ import { resolveInsideProject } from "../paths.js";
5
6
  import { portFree, portConflictMessage } from "../ports.js";
6
7
  const LINE_CAP = 5000;
7
8
  /**
@@ -97,7 +98,18 @@ export function registerProcessTools(server, cfg) {
97
98
  "bridge will NOT be reachable — use only when you want the process for its console output or side " +
98
99
  "effects and will not call any runtime_* tool against it."),
99
100
  },
100
- }, async ({ scene, allow_port_conflict }) => {
101
+ },
102
+ // 🔴 `godot_run_project`'s TWIN, in a different file, with the same defect —
103
+ // measured the same way and launching the same escaping argv. 1.42.0's lesson
104
+ // ("the second call site is the interesting one") applied before the fact.
105
+ async ({ scene, allow_port_conflict }) => {
106
+ try {
107
+ if (scene !== undefined)
108
+ resolveInsideProject(scene, cfg.projectPath, "scene");
109
+ }
110
+ catch (err) {
111
+ return failPath(err);
112
+ }
101
113
  if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
102
114
  return { isError: true, content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort) }] };
103
115
  }
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_PEERS } from "../peers.js";
3
3
  import { gate } from "../confirm.js";
4
- import { ok } from "./lsp-common.js";
4
+ import { ok, failPath } from "./lsp-common.js";
5
+ import { resolveInsideProject } from "../paths.js";
5
6
  const confirmField = {
6
7
  confirm: z.boolean().optional().describe("Auto-approve this destructive action (skip the confirmation prompt)"),
7
8
  };
@@ -77,7 +78,33 @@ function canonical(value) {
77
78
  };
78
79
  return JSON.stringify(walk(value));
79
80
  }
80
- export function registerRuntimeTools(server, runtime, peers) {
81
+ export function registerRuntimeTools(server, runtime, peers, config) {
82
+ // 🔴 NINE `.mjs` INTEGRATION PROBES CALL THIS REGISTRAR DIRECTLY, WHERE TYPESCRIPT
83
+ // CANNOT SEE THEM. Adding `config` compiled clean, passed the whole unit suite, and
84
+ // then broke six CI jobs at runtime with "Cannot read properties of undefined
85
+ // (reading 'projectPath')" — thrown from inside a guard, forty minutes in, naming
86
+ // neither the parameter nor the caller. Failing HERE turns that into a named error
87
+ // at registration. A .mjs call site is a call site; the compiler is not the roster.
88
+ if (!config?.projectPath) {
89
+ throw new Error("registerRuntimeTools requires a Config as its FOURTH argument (added 1.43.0) — " +
90
+ "got none. Direct callers in test-integration/*.mjs must pass `cfg`.");
91
+ }
92
+ /** 🔴 MEASURED against a game actually hosting the runtime bridge — rounds A–C
93
+ * answered `bridge_unavailable` for every runtime row, which is a degrade path,
94
+ * not a verdict. With the bridge up, `runtime_node_add` instantiated a PackedScene
95
+ * from outside the root and `runtime_screenshot_diff` diffed against an outside
96
+ * PNG, both through all three spellings. */
97
+ const guardPath = (p, label) => {
98
+ if (p === undefined)
99
+ return null;
100
+ try {
101
+ resolveInsideProject(p, config.projectPath, label);
102
+ return null;
103
+ }
104
+ catch (err) {
105
+ return failPath(err);
106
+ }
107
+ };
81
108
  /** The bridge addressing `peer`, or the default running game when omitted. */
82
109
  const clientFor = (peer) => (peer ? peers.clientFor(peer) : runtime);
83
110
  /** Human label for confirmation prompts, so a gated op names its target process. */
@@ -125,7 +152,9 @@ export function registerRuntimeTools(server, runtime, peers) {
125
152
  });
126
153
  server.registerTool("runtime_emit_signal", {
127
154
  title: "Runtime emit signal",
128
- description: "Emit a signal from a live node. DESTRUCTIVE — gated by confirmation.",
155
+ description: "Emit a signal from a live node. DESTRUCTIVE — gated by confirmation. " +
156
+ "args must match the arity of the signal's CONNECTED callables, or the emission fails with emit_failed and no handler runs. " +
157
+ "Argument types are not checked, and a signal with no listeners always succeeds — so emitted:true does not mean a handler ran.",
129
158
  inputSchema: { path: z.string(), signal: z.string(), args: z.array(z.any()).optional(), ...confirmField, ...peerField },
130
159
  }, async ({ path, signal, args, confirm, peer }) => {
131
160
  const blocked = await gate(server, confirm, `Emit signal "${signal}" from ${path} on ${target(peer)}`);
@@ -303,12 +332,17 @@ export function registerRuntimeTools(server, runtime, peers) {
303
332
  .describe("Optional sub-region (applied to both frame and reference) to compare"),
304
333
  ...peerField,
305
334
  },
306
- }, async ({ reference, tolerance, per_channel_threshold, region, peer }) => call("runtime.screenshot_diff", {
307
- reference,
308
- ...(tolerance !== undefined ? { tolerance } : {}),
309
- ...(per_channel_threshold !== undefined ? { per_channel_threshold } : {}),
310
- ...(region !== undefined ? { region } : {}),
311
- }, peer));
335
+ },
336
+ // `user://…` stays legal: `toFsPath` treats it as a relative spelling and joins
337
+ // it under the root, so it resolves INSIDE and the guard passes it through
338
+ // untouched — the documented mainline is preserved, and a test pins it.
339
+ async ({ reference, tolerance, per_channel_threshold, region, peer }) => guardPath(reference, "reference") ??
340
+ call("runtime.screenshot_diff", {
341
+ reference,
342
+ ...(tolerance !== undefined ? { tolerance } : {}),
343
+ ...(per_channel_threshold !== undefined ? { per_channel_threshold } : {}),
344
+ ...(region !== undefined ? { region } : {}),
345
+ }, peer));
312
346
  // F8: deterministic-verification helper. Poll a live property until it meets a
313
347
  // comparison, or a timeout elapses. Host-side over runtime.get_property (no new
314
348
  // bridge method), so it works on every engine build the runtime bridge supports.
@@ -418,6 +452,9 @@ export function registerRuntimeTools(server, runtime, peers) {
418
452
  ...peerField,
419
453
  },
420
454
  }, async ({ parent, type, scene, name, confirm, peer }) => {
455
+ const escaped = guardPath(scene, "scene");
456
+ if (escaped)
457
+ return escaped;
421
458
  const blocked = await gate(server, confirm, `Add ${scene ?? type ?? "node"} under ${parent} in ${target(peer)}`);
422
459
  if (blocked)
423
460
  return blocked;
@@ -553,6 +590,9 @@ export function registerRuntimeTools(server, runtime, peers) {
553
590
  .describe("How long to wait for every peer to answer on its bridge, in ms (default 15000)"),
554
591
  },
555
592
  }, async ({ count, scene, args, role, timeout_ms }) => {
593
+ const escaped = guardPath(scene, "scene");
594
+ if (escaped)
595
+ return escaped;
556
596
  try {
557
597
  const spawned = await peers.spawn({
558
598
  count,