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.
- package/README.md +1 -1
- package/addon/breakpoint_mcp/operations.gd +248 -13
- package/addon/breakpoint_mcp/plugin.cfg +1 -1
- package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
- package/dist/annotations.js +3 -3
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/init.js +1 -1
- package/dist/cli/tools.js +2 -2
- package/dist/csdap.js +88 -6
- package/dist/dap.js +172 -10
- package/dist/index.js +4 -4
- package/dist/path-cohort.js +177 -0
- package/dist/paths.js +111 -0
- package/dist/recipes.js +1 -1
- package/dist/schemas.js +49 -5
- package/dist/tools/assetgen.js +17 -1
- package/dist/tools/backend.js +18 -1
- package/dist/tools/cli.js +41 -4
- package/dist/tools/csdap.js +132 -8
- package/dist/tools/cslsp.js +110 -5
- package/dist/tools/dap.js +316 -59
- package/dist/tools/editor/audio.js +16 -2
- package/dist/tools/editor/common.js +39 -1
- package/dist/tools/editor/filesystem.js +23 -3
- package/dist/tools/editor/introspection.js +72 -1
- package/dist/tools/editor/node.js +7 -2
- package/dist/tools/editor/particles.js +7 -2
- package/dist/tools/editor/project_input_test.js +16 -2
- package/dist/tools/editor/resource.js +54 -7
- package/dist/tools/editor/scene.js +31 -4
- package/dist/tools/editor/shader.js +19 -3
- package/dist/tools/editor/spatial.js +16 -1
- package/dist/tools/editor/tiles.js +16 -1
- package/dist/tools/editor/ui.js +29 -2
- package/dist/tools/editor.js +20 -13
- package/dist/tools/knowledge.js +14 -1
- package/dist/tools/lsp-common.js +29 -0
- package/dist/tools/lsp.js +87 -8
- package/dist/tools/netcode.js +47 -4
- package/dist/tools/processes.js +14 -2
- package/dist/tools/runtime.js +49 -9
- package/dist/tools/tabletop.js +173 -7
- package/dist/tools/vcs.js +138 -22
- package/dist/toolsets.js +2 -2
- package/package.json +8 -2
package/dist/tools/editor/ui.js
CHANGED
|
@@ -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
|
-
},
|
|
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;
|
package/dist/tools/editor.js
CHANGED
|
@@ -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
|
package/dist/tools/knowledge.js
CHANGED
|
@@ -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 {
|
package/dist/tools/lsp-common.js
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
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);
|
package/dist/tools/netcode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { gate } from "../confirm.js";
|
|
3
|
-
import {
|
|
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
|
-
},
|
|
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
|
-
|
|
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: "
|
|
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;
|
package/dist/tools/processes.js
CHANGED
|
@@ -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
|
-
},
|
|
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
|
}
|
package/dist/tools/runtime.js
CHANGED
|
@@ -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
|
-
},
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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,
|