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/schemas.js
CHANGED
|
@@ -102,6 +102,8 @@ export const outputSchemas = {
|
|
|
102
102
|
child_count: z.number(),
|
|
103
103
|
children: z.array(sceneNode).optional(),
|
|
104
104
|
},
|
|
105
|
+
main_screen_get: { active: z.string().nullable(), available: z.array(z.string()) },
|
|
106
|
+
main_screen_set: { active: z.string().nullable(), available: z.array(z.string()), requested: z.string() },
|
|
105
107
|
scene_open: { opened: z.string() },
|
|
106
108
|
scene_save: { saved: z.string() },
|
|
107
109
|
scene_new: { created: z.string(), root_type: z.string() },
|
|
@@ -109,7 +111,16 @@ export const outputSchemas = {
|
|
|
109
111
|
scene_reload: { reloaded: z.string() },
|
|
110
112
|
scene_close: { closed: z.string() },
|
|
111
113
|
scene_pack: { packed: z.string(), branch: z.string() },
|
|
112
|
-
|
|
114
|
+
// `dependencies` are LOADABLE res:// paths as of 1.48.0; `dependencies_raw` keeps the
|
|
115
|
+
// engine's own `uid://X::::res://Y` encoding, which is what the field used to carry and
|
|
116
|
+
// which resource_load answers `not_found` for (169 §5). `dependency_uids` is
|
|
117
|
+
// index-aligned with both, "" where the dependency has no UID.
|
|
118
|
+
scene_get_dependencies: {
|
|
119
|
+
path: z.string(),
|
|
120
|
+
dependencies: z.array(z.string()),
|
|
121
|
+
dependencies_raw: z.array(z.string()),
|
|
122
|
+
dependency_uids: z.array(z.string()),
|
|
123
|
+
},
|
|
113
124
|
scene_save_as: { saved_as: z.string() },
|
|
114
125
|
node_add: { path: z.string(), name: z.string(), type: z.string() },
|
|
115
126
|
node_delete: { deleted: z.string() },
|
|
@@ -169,11 +180,19 @@ export const outputSchemas = {
|
|
|
169
180
|
resource_get_property: { path: z.string(), property: z.string(), value: encodedValue },
|
|
170
181
|
resource_set_property: { path: z.string(), property: z.string(), value: encodedValue },
|
|
171
182
|
resource_get_import_settings: { path: z.string(), imported: z.boolean(), importer: z.string(), settings: z.record(encodedValue) },
|
|
172
|
-
|
|
183
|
+
// `changed` (1.46.0) is narrower than `settings`: `settings` lists every key the call
|
|
184
|
+
// SET, `changed` only those whose stored value actually moved. Setting a key to the
|
|
185
|
+
// value it already held reported `reimported: true` with the sidecar's bytes unmoved
|
|
186
|
+
// until 1.46.0 — see the note in operations.gd's _resource_set_import_settings.
|
|
187
|
+
resource_set_import_settings: { path: z.string(), reimported: z.boolean(), settings: z.array(z.string()), changed: z.array(z.string()) },
|
|
173
188
|
// ---- Group B: filesystem (tools/editor.ts -> operations.gd _filesystem_*) ----
|
|
174
189
|
filesystem_list: { path: z.string(), dirs: z.array(z.string()), files: z.array(z.string()) },
|
|
175
190
|
filesystem_scan: { scanning: z.boolean() },
|
|
176
|
-
|
|
191
|
+
// `import_stranded` (173): the .import sidecar EXISTED and would not move. Separate
|
|
192
|
+
// from `moved_import: false`, which also means "there was no sidecar" — one boolean
|
|
193
|
+
// cannot carry both, and the difference is a moved asset that has lost its import
|
|
194
|
+
// settings versus one that never had any.
|
|
195
|
+
filesystem_move: { moved: z.string(), from: z.string(), moved_import: z.boolean(), import_stranded: z.boolean() },
|
|
177
196
|
filesystem_create_dir: { created: z.string(), existed: z.boolean() },
|
|
178
197
|
// ---- Group C: animation (tools/editor.ts -> operations.gd _anim_*) ----
|
|
179
198
|
anim_player_create: { path: z.string(), name: z.string(), type: z.string() },
|
|
@@ -368,13 +387,33 @@ export const outputSchemas = {
|
|
|
368
387
|
actions: z.array(z.object({ title: z.string(), kind: z.string(), has_edit: z.boolean(), command: z.string().nullable() })),
|
|
369
388
|
},
|
|
370
389
|
// ---- Plane D: debugging / DAP (tools/dap.ts) ----
|
|
371
|
-
dbg_launch: {
|
|
372
|
-
|
|
390
|
+
dbg_launch: {
|
|
391
|
+
session_id: z.string(), state: z.string(), scene: z.string(),
|
|
392
|
+
// Present only when stop_on_entry was requested: whether an entry stop actually
|
|
393
|
+
// landed. The Godot adapter does not implement stopOnEntry, so it reports false.
|
|
394
|
+
// NOTE: keep single quotes out of comments in this file (155 §7). An unpaired
|
|
395
|
+
// one inside a shape makes contract_check.py swallow every later entry into
|
|
396
|
+
// this one, and the drift error then names every field in the file.
|
|
397
|
+
stop_on_entry_honored: z.boolean().optional(),
|
|
398
|
+
// Modifiers dropped when the handshake applied the BUFFERED breakpoints. Buffered
|
|
399
|
+
// modifiers cannot be feature-detected at set time, so this is where their fate is
|
|
400
|
+
// reported; dbg_set_breakpoints said only that detection was deferred.
|
|
401
|
+
unsupported_modifiers: z.array(z.string()).optional(),
|
|
402
|
+
warning: z.string().optional(),
|
|
403
|
+
},
|
|
404
|
+
dbg_attach: {
|
|
405
|
+
session_id: z.string(), state: z.string(),
|
|
406
|
+
unsupported_modifiers: z.array(z.string()).optional(),
|
|
407
|
+
warning: z.string().optional(),
|
|
408
|
+
},
|
|
373
409
|
dbg_set_breakpoints: {
|
|
374
410
|
path: z.string(), buffered: z.boolean(),
|
|
375
411
|
breakpoints: z.array(z.object({ line: z.number(), verified: z.boolean() })),
|
|
376
412
|
// Present only when the adapter advertised a requested modifier unsupported (see tools/dap.ts).
|
|
377
413
|
unsupported_modifiers: z.array(z.string()).optional(),
|
|
414
|
+
// Present when modifiers were buffered before any session existed: the adapter had
|
|
415
|
+
// advertised nothing yet, so nothing could be detected. Value: deferred.
|
|
416
|
+
modifier_detection: z.string().optional(),
|
|
378
417
|
warning: z.string().optional(),
|
|
379
418
|
},
|
|
380
419
|
dbg_continue: { state: z.string(), stopped_reason: z.string().nullable() },
|
|
@@ -609,6 +648,7 @@ export const outputSchemas = {
|
|
|
609
648
|
name: z.string(), short_sha: z.string(), current: z.boolean(), remote: z.boolean(),
|
|
610
649
|
})),
|
|
611
650
|
count: z.number(),
|
|
651
|
+
detached: z.boolean(),
|
|
612
652
|
},
|
|
613
653
|
vcs_blame: {
|
|
614
654
|
path: z.string(),
|
|
@@ -631,8 +671,12 @@ export const outputSchemas = {
|
|
|
631
671
|
summary: z.string(),
|
|
632
672
|
},
|
|
633
673
|
vcs_restore: {
|
|
674
|
+
// 🔴 `restored` IS MEASURED, NOT ECHOED (D5, 155 §2). It used to be the requested
|
|
675
|
+
// paths handed straight back, so a clean path reported as discarded work.
|
|
634
676
|
restored: z.array(z.string()),
|
|
635
677
|
count: z.number(),
|
|
678
|
+
requested: z.array(z.string()),
|
|
679
|
+
stranded: z.array(z.string()),
|
|
636
680
|
},
|
|
637
681
|
vcs_stash: {
|
|
638
682
|
op: z.string(),
|
package/dist/tools/assetgen.js
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { gate } from "../confirm.js";
|
|
5
|
-
import { toFsPath } from "../paths.js";
|
|
5
|
+
import { toFsPath, resolveInsideProject } from "../paths.js";
|
|
6
|
+
import { failPath } from "./lsp-common.js";
|
|
6
7
|
/**
|
|
7
8
|
* Group J — AI asset generation.
|
|
8
9
|
*
|
|
@@ -179,6 +180,21 @@ export function registerAssetGenTools(server, bridge, config) {
|
|
|
179
180
|
// The shared generation flow for the 5 typed generators + the always-on placeholder.
|
|
180
181
|
async function generate(kind, args) {
|
|
181
182
|
const { prompt, to_path, width, height, duration_ms, shape, placeholder, confirm, forcePlaceholder } = args;
|
|
183
|
+
// 🔴 BEFORE THE BACKEND CHECK, NOT AFTER — 163 §3's shape. All six generators
|
|
184
|
+
// funnel through here, and MEASURED against a live editor every one of them wrote
|
|
185
|
+
// outside the root through `res://../`. Refusing first means the refusal costs no
|
|
186
|
+
// bridge call and no backend, so a unit test reaches it with `backend: "none"`.
|
|
187
|
+
//
|
|
188
|
+
// 🔴 THIS IS THE ONE BEHAVIOUR CHANGE. With no backend configured, an escaping
|
|
189
|
+
// `to_path` used to come back `no_backend` carrying a `request` spec — which was
|
|
190
|
+
// an instruction to the CALLER to write outside the project. It now refuses. That
|
|
191
|
+
// is the honest answer, and it is pinned by a test rather than left to chance.
|
|
192
|
+
try {
|
|
193
|
+
resolveInsideProject(to_path, config.projectPath, "to_path");
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
return failPath(err);
|
|
197
|
+
}
|
|
182
198
|
const spec = KIND_FORMAT[kind];
|
|
183
199
|
const usePlaceholder = forcePlaceholder === true || placeholder === true || state.backend === "placeholder";
|
|
184
200
|
const effectiveBackend = usePlaceholder ? "placeholder" : state.backend;
|
package/dist/tools/backend.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { gate } from "../confirm.js";
|
|
3
|
+
import { resolveInsideProject } from "../paths.js";
|
|
3
4
|
/**
|
|
4
5
|
* Group M (second half) — Backend-SDK integration scaffolding (`backend_*`,
|
|
5
6
|
* `*_scaffold`).
|
|
@@ -334,7 +335,7 @@ export function buildCloudSaveScript(sdk) {
|
|
|
334
335
|
}
|
|
335
336
|
}
|
|
336
337
|
// ------------------------------------------------------------- registration ----
|
|
337
|
-
export function registerBackendTools(server, bridge,
|
|
338
|
+
export function registerBackendTools(server, bridge, config) {
|
|
338
339
|
const call = async (method, params = {}) => bridge.request(method, params);
|
|
339
340
|
async function detect() {
|
|
340
341
|
const r = (await call("backend.detect"));
|
|
@@ -354,6 +355,22 @@ export function registerBackendTools(server, bridge, _config) {
|
|
|
354
355
|
if (!opts.toPath.startsWith("res://") || !opts.toPath.endsWith(".gd")) {
|
|
355
356
|
return fail({ code: "bad_params", message: "'to_path' must be a res:// .gd path" });
|
|
356
357
|
}
|
|
358
|
+
// 🔴 `res://../` SATISFIES THE PREFIX CHECK ABOVE and, measured against a live
|
|
359
|
+
// editor, wrote all four scaffolds outside the project root. The same defect
|
|
360
|
+
// netcode.ts fixed at its three call sites in 161 — and this file calls the SAME
|
|
361
|
+
// addon method, `mp.write_script`. One helper guarded, its twin not: the fourth
|
|
362
|
+
// consecutive session where the second call site was the interesting one.
|
|
363
|
+
//
|
|
364
|
+
// 🔴 IT GOES BEFORE THE CAPABILITY AND INSTALLED CHECKS, not after. The first run
|
|
365
|
+
// of this session's probe put sixteen backend rows through an uninstalled SDK and
|
|
366
|
+
// measured `sdk_missing` — the DETECTOR, not the path. A guard that sits behind a
|
|
367
|
+
// feature gate is a guard nobody can measure.
|
|
368
|
+
try {
|
|
369
|
+
resolveInsideProject(opts.toPath, config.projectPath, "to_path");
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return fail(err);
|
|
373
|
+
}
|
|
357
374
|
// (1) capability — intrinsic to the SDK, no bridge needed.
|
|
358
375
|
if (!hasCapability(opts.sdk, opts.feature)) {
|
|
359
376
|
return ok({
|
package/dist/tools/cli.js
CHANGED
|
@@ -3,7 +3,8 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { log } from "../logger.js";
|
|
5
5
|
import { registerTaskTool } from "../tasks.js";
|
|
6
|
-
import { ok } from "./lsp-common.js";
|
|
6
|
+
import { ok, failPath } from "./lsp-common.js";
|
|
7
|
+
import { resolveInsideProject } from "../paths.js";
|
|
7
8
|
import { portFree, portConflictMessage } from "../ports.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
10
|
/** Run the Godot binary to completion, capturing stdout/stderr. */
|
|
@@ -76,7 +77,19 @@ export function registerCliTools(server, cfg) {
|
|
|
76
77
|
"bridge will NOT be reachable — use only when you want the process for its side effects and will not " +
|
|
77
78
|
"call any runtime_* tool against it."),
|
|
78
79
|
},
|
|
79
|
-
},
|
|
80
|
+
},
|
|
81
|
+
// 🔴 MEASURED by the LAUNCHED PROCESS'S OWN ARGV: `res://../example_evil/x.tscn`
|
|
82
|
+
// produced `godot --path …/example res://../example_evil/x.tscn` and the game
|
|
83
|
+
// ran it. The port check comes second — a scene that can never legally run
|
|
84
|
+
// should not first claim the runtime port.
|
|
85
|
+
async ({ scene, allow_port_conflict }) => {
|
|
86
|
+
try {
|
|
87
|
+
if (scene !== undefined)
|
|
88
|
+
resolveInsideProject(scene, cfg.projectPath, "scene");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return failPath(err);
|
|
92
|
+
}
|
|
80
93
|
if (!allow_port_conflict && !(await portFree(cfg.runtimeHost, cfg.runtimePort))) {
|
|
81
94
|
return { isError: true, content: [{ type: "text", text: portConflictMessage(cfg.runtimeHost, cfg.runtimePort) }] };
|
|
82
95
|
}
|
|
@@ -96,7 +109,20 @@ export function registerCliTools(server, cfg) {
|
|
|
96
109
|
debug: z.boolean().optional().describe("Export a debug build instead of release (default false)"),
|
|
97
110
|
timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
|
|
98
111
|
},
|
|
99
|
-
},
|
|
112
|
+
},
|
|
113
|
+
// 🔴 A WRITER THE `to_path` SWEEP MISSED ON A NAMING ACCIDENT. `output_path` goes
|
|
114
|
+
// to the Godot CLI verbatim, and the CLI honours an escaping one: measured with
|
|
115
|
+
// the equivalent `--export-pack "Linux/X11" ../example_evil/g165_pack.pck`, which
|
|
116
|
+
// put a 306KB .pck outside the root. (The tool's own `--export-release` could not
|
|
117
|
+
// be run to completion on the measuring Mac — no export templates installed — so
|
|
118
|
+
// this row is CLI-measured plus source-verified passthrough, not tool-measured.)
|
|
119
|
+
async ({ preset, output_path, debug, timeout_ms }, signal) => {
|
|
120
|
+
try {
|
|
121
|
+
resolveInsideProject(output_path, cfg.projectPath, "output_path");
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
return failPath(err);
|
|
125
|
+
}
|
|
100
126
|
const flag = debug ? "--export-debug" : "--export-release";
|
|
101
127
|
const r = await runCaptured(cfg, ["--headless", "--path", cfg.projectPath, flag, preset, output_path], timeout_ms ?? 600000, signal);
|
|
102
128
|
return ok({
|
|
@@ -129,7 +155,18 @@ export function registerCliTools(server, cfg) {
|
|
|
129
155
|
args: z.array(z.string()).optional().describe("Extra CLI args passed after the script"),
|
|
130
156
|
timeout_ms: z.number().int().positive().optional().describe("Max run time (default 600000)"),
|
|
131
157
|
},
|
|
132
|
-
},
|
|
158
|
+
},
|
|
159
|
+
// 🔴🔴 THE SHARPEST ROW OF THE SESSION. `-s <script_path>` EXECUTES the file. A
|
|
160
|
+
// script at `res://../example_evil/g165_run.gd` RAN — proven by the marker file it
|
|
161
|
+
// wrote, not by the reply, which answered `exit_code: 0` for the real script AND
|
|
162
|
+
// for one that did not exist. The reply channel carried zero information.
|
|
163
|
+
async ({ script_path, args, timeout_ms }, signal) => {
|
|
164
|
+
try {
|
|
165
|
+
resolveInsideProject(script_path, cfg.projectPath, "script_path");
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return failPath(err);
|
|
169
|
+
}
|
|
133
170
|
const r = await runCaptured(cfg, ["--headless", "--path", cfg.projectPath, "-s", script_path, ...(args ?? [])], timeout_ms ?? 600000, signal);
|
|
134
171
|
return ok({
|
|
135
172
|
script_path,
|
package/dist/tools/csdap.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { DapError } from "../dap.js";
|
|
3
|
-
import {
|
|
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";
|
|
@@ -10,11 +10,41 @@ import { portFree, portConflictMessage } from "../ports.js";
|
|
|
10
10
|
const RESUME_WAIT_MS = 15000;
|
|
11
11
|
function fail(err) {
|
|
12
12
|
const e = err;
|
|
13
|
+
// A REFUSAL is the host declining the call, not the debug adapter failing one —
|
|
14
|
+
// the same distinction `lsp-common.fail()` draws. Rendering it as
|
|
15
|
+
// "C# DAP error [...]" sends the caller to debug an adapter that was never asked.
|
|
16
|
+
if (e?.refusal) {
|
|
17
|
+
return {
|
|
18
|
+
isError: true,
|
|
19
|
+
content: [{ type: "text", text: e.message ?? String(err) }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// 🔴 An adapter failure can carry NO message at all. Measured: netcoredbg
|
|
23
|
+
// advertises `supportsSetVariable: true` and answered a setVariable failure with an
|
|
24
|
+
// empty `message`, so this rendered as the bare, meaningless
|
|
25
|
+
// `C# DAP error [setVariable]: ` — an error whose text says nothing about what went
|
|
26
|
+
// wrong or who reported it. Say that the adapter reported a failure without one.
|
|
27
|
+
const message = (e?.message ?? "").trim();
|
|
28
|
+
const command = e?.command ?? "error";
|
|
29
|
+
if (message === "") {
|
|
30
|
+
return {
|
|
31
|
+
isError: true,
|
|
32
|
+
content: [{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: `C# DAP error [${command}]: the debug adapter reported a failure with no message. ` +
|
|
35
|
+
`The request was rejected; nothing was changed by it.`,
|
|
36
|
+
}],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
13
39
|
return {
|
|
14
40
|
isError: true,
|
|
15
|
-
content: [{ type: "text", text: `C# DAP error [${
|
|
41
|
+
content: [{ type: "text", text: `C# DAP error [${command}]: ${message}` }],
|
|
16
42
|
};
|
|
17
43
|
}
|
|
44
|
+
/** Throw a host refusal — rendered verbatim by `fail()`, never as an adapter error. */
|
|
45
|
+
function refuse(message, code) {
|
|
46
|
+
throw Object.assign(new Error(message), { refusal: true, code });
|
|
47
|
+
}
|
|
18
48
|
/**
|
|
19
49
|
* True when `err` is a DAP request that hit its own request deadline (not an
|
|
20
50
|
* adapter-reported failure or a dropped connection). Used to turn a
|
|
@@ -43,13 +73,74 @@ function isDapTimeout(err) {
|
|
|
43
73
|
* `supportsGotoTargetsRequest` nor `supportsDataBreakpoints`, so those tools
|
|
44
74
|
* would only ever return "unsupported" here.
|
|
45
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* 🔴 THE PLANE WHOSE GUARD IS DELIBERATELY DIFFERENT, and the one 161 §8 item 5 said
|
|
78
|
+
* to find before folding anything together. `anchoredOnly` is the whole difference:
|
|
79
|
+
*
|
|
80
|
+
* - `res://…` and relative paths are PROJECT-ANCHORED by definition; one that
|
|
81
|
+
* resolves outside the root (`res://../../../etc/passwd` landed in
|
|
82
|
+
* `~/Downloads/etc/passwd`) is meaningless and is refused.
|
|
83
|
+
* - an ABSOLUTE path is the caller explicitly naming a file elsewhere, which is
|
|
84
|
+
* exactly how you debug another program — `cs_dbg_launch` documents overriding
|
|
85
|
+
* `program` for precisely that. It stays legal.
|
|
86
|
+
*
|
|
87
|
+
* Measured, session 162, and this is not a reading of the code: `<csroot>_evil/…` and
|
|
88
|
+
* a plain `elsewhere/…` both answered ok through the real stdio server, while every
|
|
89
|
+
* project-anchored escape was refused. The cs-dap gate DEPENDS on this — its own
|
|
90
|
+
* throwaway fixture source is an absolute path outside the project, and §4 of that
|
|
91
|
+
* probe is built on breakpoints in it. Flattening this plane onto the other three
|
|
92
|
+
* would have broken the documented mainline of the tool AND its gate.
|
|
93
|
+
*
|
|
94
|
+
* Existence is checked for BOTH forms: that guard asks whether a breakpoint can bind
|
|
95
|
+
* at all, and it is location-independent.
|
|
96
|
+
*/
|
|
97
|
+
const CS_DAP_PATHS = {
|
|
98
|
+
root: "the C# project root",
|
|
99
|
+
escapeHint: "A res:// or relative path is project-anchored; pass an absolute path to set a " +
|
|
100
|
+
"breakpoint in a program outside the project.",
|
|
101
|
+
missingHint: "A breakpoint there can never bind, but it was previously answered exactly like " +
|
|
102
|
+
"one on a real file.",
|
|
103
|
+
emptyNote: " (an empty path resolves to the project root)",
|
|
104
|
+
anchoredOnly: true,
|
|
105
|
+
};
|
|
46
106
|
export function registerCsDapTools(server, dap, cfg) {
|
|
107
|
+
const root = cfg.csDapProjectPath;
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a breakpoint source path and REFUSE one that cannot carry a breakpoint.
|
|
110
|
+
*
|
|
111
|
+
* Measured against a real netcoredbg: `res://NoSuchFile.cs`, `res://demo` (a
|
|
112
|
+
* DIRECTORY) and `""` (which `path.join`s down to the PROJECT ROOT DIRECTORY) each
|
|
113
|
+
* answered `{buffered:true, breakpoints:[]}` with `isError:false` — byte-identical
|
|
114
|
+
* to `res://Player.cs`. The caller could not tell an armed breakpoint from one that
|
|
115
|
+
* can never bind.
|
|
116
|
+
*
|
|
117
|
+
* 🔴 The escape check is deliberately NARROWER than the `cs_*` LSP plane's, and the
|
|
118
|
+
* difference is not an oversight. `cs_dbg_launch` documents overriding `program` to
|
|
119
|
+
* "debug a different .NET program", whose sources legitimately live outside the
|
|
120
|
+
* Godot C# project — refusing every absolute path outside the root would break the
|
|
121
|
+
* documented mainline of this tool. So:
|
|
122
|
+
*
|
|
123
|
+
* - `res://…` and relative paths are PROJECT-ANCHORED by definition; one that
|
|
124
|
+
* resolves outside the root (`res://../../../etc/passwd` landed in
|
|
125
|
+
* `~/Downloads/etc/passwd`) is meaningless and is refused.
|
|
126
|
+
* - an ABSOLUTE path is the caller explicitly naming a file elsewhere, which is
|
|
127
|
+
* exactly how you debug another program. It stays legal.
|
|
128
|
+
*
|
|
129
|
+
* Existence is checked for BOTH forms — that guard is about whether a breakpoint
|
|
130
|
+
* can bind at all, and it is location-independent. The comparison is against
|
|
131
|
+
* `root + path.sep`, never a bare `startsWith(root)`: the latter accepts a sibling
|
|
132
|
+
* directory that merely shares the root's name prefix.
|
|
133
|
+
*/
|
|
134
|
+
const guardSource = (p) => resolveSourceFile(p, root, CS_DAP_PATHS);
|
|
47
135
|
server.registerTool("cs_dbg_launch", {
|
|
48
136
|
title: "Launch C# debug session",
|
|
49
137
|
description: "Start a C# Godot game under netcoredbg. `program` defaults to the configured Mono/.NET Godot binary " +
|
|
50
138
|
"(GODOT_CSHARP_BIN) and `args` to ['--path', <C# project>]; override either to debug a different .NET program. " +
|
|
51
139
|
"Any breakpoints set beforehand are applied during the handshake. Requires netcoredbg (GODOT_CSDAP_CMD) — " +
|
|
52
|
-
"absent, the lazy spawn fails with an actionable hint rather than hanging."
|
|
140
|
+
"absent, the lazy spawn fails with an actionable hint rather than hanging. A launch the adapter rejects " +
|
|
141
|
+
"(a program that does not exist, or is not a .NET assembly) is reported as an error: it previously answered " +
|
|
142
|
+
"state 'running' for a session that never started. With `stop_on_entry` the call waits for the entry stop, " +
|
|
143
|
+
"so it returns state 'stopped' with a usable thread — not 'running'.",
|
|
53
144
|
inputSchema: {
|
|
54
145
|
program: z.string().optional().describe("Path to the program to launch (default: the Mono/.NET Godot binary)"),
|
|
55
146
|
args: z.array(z.string()).optional().describe("Program arguments (default: ['--path', <C# project>])"),
|
|
@@ -105,12 +196,29 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
105
196
|
server.registerTool("cs_dbg_attach", {
|
|
106
197
|
title: "Attach C# debug session",
|
|
107
198
|
description: "Attach netcoredbg to an already-running .NET process (e.g. a C# Godot game launched separately) by its OS process id. " +
|
|
108
|
-
"Any breakpoints set beforehand are applied during the handshake."
|
|
199
|
+
"Any breakpoints set beforehand are applied during the handshake. A process id nothing is running under is refused " +
|
|
200
|
+
"rather than reported as an attached session; a process owned by another user is a legitimate target and is not refused.",
|
|
109
201
|
inputSchema: {
|
|
110
|
-
|
|
202
|
+
// .positive(): a pid is 1 or greater. `-1` and `0` are not process ids, and
|
|
203
|
+
// both were measured answering `isError:false state:"running"` — a phantom
|
|
204
|
+
// session against a process that cannot exist.
|
|
205
|
+
process_id: z.number().int().positive().describe("OS process id of the running .NET process to attach to"),
|
|
111
206
|
},
|
|
112
207
|
}, async ({ process_id }) => {
|
|
113
208
|
try {
|
|
209
|
+
// Refuse a pid nothing is running under, BEFORE the handshake. `kill(pid, 0)`
|
|
210
|
+
// signals nothing; it only asks the kernel about the process. ESRCH means no
|
|
211
|
+
// such process. EPERM means it EXISTS and is owned by someone else — that is a
|
|
212
|
+
// legitimate attach target, so it must NOT be refused here.
|
|
213
|
+
try {
|
|
214
|
+
process.kill(process_id, 0);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
if (err?.code === "ESRCH") {
|
|
218
|
+
refuse(`Refusing to attach to process ${process_id}: no such process. The debug session ` +
|
|
219
|
+
`was previously reported as "running" against a process that does not exist.`, "no_such_process");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
114
222
|
await dap.start("attach", { processId: process_id });
|
|
115
223
|
return ok({ session_id: "csharp", state: dap.state });
|
|
116
224
|
}
|
|
@@ -123,7 +231,10 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
123
231
|
description: "Set (replace) the breakpoints for a C# source file. Applied immediately if a session is running, else buffered until launch/attach. " +
|
|
124
232
|
"Feature-detected: the per-line `conditions` modifier is only sent when the connected adapter advertises supportsConditionalBreakpoints " +
|
|
125
233
|
"(netcoredbg does); on an adapter that advertises it unsupported the modifier is dropped and the result carries `unsupported_modifiers` " +
|
|
126
|
-
"plus a `warning`. Detection needs a live session, so set conditions after cs_dbg_launch/cs_dbg_attach."
|
|
234
|
+
"plus a `warning`. Detection needs a live session, so set conditions after cs_dbg_launch/cs_dbg_attach. " +
|
|
235
|
+
"A path that names nothing, or names a directory, is refused: it previously answered exactly like a real file. " +
|
|
236
|
+
"A res:// or relative path that resolves outside the C# project root is refused too; an absolute path elsewhere " +
|
|
237
|
+
"is legal, because debugging a different .NET program is supported.",
|
|
127
238
|
inputSchema: {
|
|
128
239
|
path: z.string().describe("C# script path (res://..., absolute, or relative to the C# project root)"),
|
|
129
240
|
lines: z.array(z.number().int().positive()).describe("1-based line numbers"),
|
|
@@ -131,7 +242,7 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
131
242
|
},
|
|
132
243
|
}, async ({ path, lines, conditions }) => {
|
|
133
244
|
try {
|
|
134
|
-
const fsPath =
|
|
245
|
+
const fsPath = guardSource(path);
|
|
135
246
|
// Feature-detect the condition modifier against the connected adapter. Only when it does
|
|
136
247
|
// not advertise supportsConditionalBreakpoints do we DROP conditions and warn — otherwise
|
|
137
248
|
// a "conditional" breakpoint could halt unconditionally on an adapter that ignores them.
|
|
@@ -346,7 +457,8 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
346
457
|
description: "Enable (replace) the debugger's exception breakpoint filters so execution halts when a matching .NET exception is thrown " +
|
|
347
458
|
"(DAP setExceptionBreakpoints). Pass the filter IDs to enable; call with no filters (or []) to clear them. The result echoes the " +
|
|
348
459
|
"active filters and lists `available_filters` — the exception filters the connected adapter advertises (netcoredbg exposes `all` " +
|
|
349
|
-
"for every thrown exception and `user-unhandled`).
|
|
460
|
+
"for every thrown exception and `user-unhandled`). A filter id the adapter does not advertise is refused by name, listing the real " +
|
|
461
|
+
"ones, rather than forwarded to the adapter as an opaque failure. Requires a running debug session. Not gated (it only configures the debugger). " +
|
|
350
462
|
"Feature-detected: on an adapter that advertises no exceptionBreakpointFilters it returns a clear \"unsupported\" message WITHOUT sending anything.",
|
|
351
463
|
inputSchema: {
|
|
352
464
|
filters: z.array(z.string()).optional().describe("Exception filter IDs to enable (default none = clear). Choose from available_filters in the result (netcoredbg: 'all', 'user-unhandled')."),
|
|
@@ -367,6 +479,18 @@ export function registerCsDapTools(server, dap, cfg) {
|
|
|
367
479
|
};
|
|
368
480
|
}
|
|
369
481
|
const active = filters ?? [];
|
|
482
|
+
// 🔴 The empty case was validated; MEMBERSHIP was not. An id the adapter never
|
|
483
|
+
// advertised went straight to the wire and came back
|
|
484
|
+
// `Failed command 'setExceptionBreakpoints' : 0x80070057` — a hex code for a
|
|
485
|
+
// question the host already had the answer to, since `available_filters` is
|
|
486
|
+
// right here. Name the bad filter and list the real ones instead.
|
|
487
|
+
const known = new Set(available_filters.map((f) => f.filter));
|
|
488
|
+
const unknown = active.filter((f) => !known.has(f));
|
|
489
|
+
if (unknown.length) {
|
|
490
|
+
refuse(`Refusing exception filter(s) ${unknown.map((f) => JSON.stringify(f)).join(", ")}: the ` +
|
|
491
|
+
`connected C# debug adapter does not advertise them. Available: ` +
|
|
492
|
+
`${available_filters.map((f) => f.filter).join(", ")}.`, "unknown_exception_filter");
|
|
493
|
+
}
|
|
370
494
|
const body = await dap.request("setExceptionBreakpoints", { filters: active });
|
|
371
495
|
const breakpoints = Array.isArray(body["breakpoints"])
|
|
372
496
|
? body["breakpoints"].map((b) => ({ verified: Boolean(b.verified) }))
|
package/dist/tools/cslsp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, normalizeWorkspaceEdit, } from "./lsp-common.js";
|
|
6
6
|
/**
|
|
@@ -32,17 +32,100 @@ function unsupportedCsLsp(tool, method, capability, alt) {
|
|
|
32
32
|
}],
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A C# identifier: the only thing `cs_rename` may rename a symbol TO.
|
|
37
|
+
*
|
|
38
|
+
* This is the spec shape (identifier-start-character then identifier-part-characters),
|
|
39
|
+
* NOT an ASCII approximation — `Ångström` and `日本語` are legal C# identifiers and a
|
|
40
|
+
* rename to one produces a file that compiles, so refusing them would be wrong.
|
|
41
|
+
*
|
|
42
|
+
* The leading `@` is C#'s verbatim-identifier prefix: `@class` IS a legal identifier
|
|
43
|
+
* precisely BECAUSE it escapes the keyword, so a `@`-prefixed name skips the reserved
|
|
44
|
+
* word check below rather than being refused by it.
|
|
45
|
+
*/
|
|
46
|
+
const CS_IDENTIFIER = /^@?[\p{L}\p{Nl}_][\p{L}\p{Nl}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\p{Cf}]*$/u;
|
|
47
|
+
/**
|
|
48
|
+
* C# **reserved** keywords. A rename to one of these is shaped like an identifier but
|
|
49
|
+
* produces a file the compiler rejects, so it is refused for the same reason.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately excludes CONTEXTUAL keywords (`var`, `value`, `async`, `await`, `yield`,
|
|
52
|
+
* `nameof`, `record`, `when`, `from`, `select`, …) — those are legal identifiers and a
|
|
53
|
+
* rename to one compiles. Framework type names (`Console`, `String`, `Task`) are legal
|
|
54
|
+
* too and are likewise not listed, the same call `gd_rename` makes for engine classes.
|
|
55
|
+
*/
|
|
56
|
+
const CS_KEYWORDS = new Set([
|
|
57
|
+
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked",
|
|
58
|
+
"class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else",
|
|
59
|
+
"enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for",
|
|
60
|
+
"foreach", "goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock",
|
|
61
|
+
"long", "namespace", "new", "null", "object", "operator", "out", "override", "params",
|
|
62
|
+
"private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed",
|
|
63
|
+
"short", "sizeof", "stackalloc", "static", "string", "struct", "switch", "this",
|
|
64
|
+
"throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort",
|
|
65
|
+
"using", "virtual", "void", "volatile", "while",
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* This plane's shipped refusal wording, kept verbatim while the guard itself moved to
|
|
69
|
+
* `paths.ts` (161 §8 item 5). `cs-lsp-plane.integration.mjs` pins /outside the C#
|
|
70
|
+
* project root/ by regex, so the `root` phrase here is a contract, not decoration —
|
|
71
|
+
* and it is the reason `paths.ts` takes the wording as a parameter instead of
|
|
72
|
+
* hardcoding "Godot" the way the tabletop helper does.
|
|
73
|
+
*
|
|
74
|
+
* Measured against OmniSharp v1.39.15: `res://../../../etc/passwd`, a bare
|
|
75
|
+
* `/etc/passwd` and `res://../../README.md` each resolved outside the project root and
|
|
76
|
+
* were answered with `isError: false`. And this plane had the SHARPER existence repro:
|
|
77
|
+
* `cs_document_symbols` returned byte-identical `{"symbols":[]}` for a MISSING file,
|
|
78
|
+
* a genuinely EMPTY one, and a DIRECTORY — three states, one answer.
|
|
79
|
+
*/
|
|
80
|
+
const CS_LSP_PATHS = {
|
|
81
|
+
root: "the C# project root",
|
|
82
|
+
escapeHint: "Pass a res:// path, or a path inside the project.",
|
|
83
|
+
missingHint: "It was previously opened as an EMPTY document, so the language server answered " +
|
|
84
|
+
"about a file that does not exist.",
|
|
85
|
+
};
|
|
35
86
|
export function registerCsLspTools(server, cslsp, cfg) {
|
|
36
87
|
const root = cfg.csLspProjectPath;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a caller-supplied script path and REFUSE one that escapes the C# project
|
|
90
|
+
* root, names nothing, or names something that is not a regular file. This is the
|
|
91
|
+
* same guard the `gd_*` plane grew in 1.34.0, ported after measuring the `cs_*`
|
|
92
|
+
* plane against a real OmniSharp — the defects are the same shape, but two of them
|
|
93
|
+
* present differently, and the comments record what was measured HERE, not there.
|
|
94
|
+
*
|
|
95
|
+
* `toFsPath` joins through `path.join`, which normalizes `..` away silently.
|
|
96
|
+
* Measured against OmniSharp v1.39.15: `res://../../../etc/passwd`, a bare
|
|
97
|
+
* `/etc/passwd` and `res://../../README.md` each resolved outside the project root
|
|
98
|
+
* and were answered with `isError: false`. Nothing leaked — the answer was the same
|
|
99
|
+
* degenerate one any unloaded path produces — but nothing refused them either.
|
|
100
|
+
*
|
|
101
|
+
* The comparison is against `root + path.sep`, never a bare `startsWith(root)`: the
|
|
102
|
+
* latter accepts a SIBLING directory that merely shares the root's name prefix.
|
|
103
|
+
*/
|
|
104
|
+
const guardPath = (p) => {
|
|
105
|
+
resolveSourceFile(p, root, CS_LSP_PATHS);
|
|
106
|
+
};
|
|
37
107
|
const openAndPos = async (path) => {
|
|
108
|
+
guardPath(path);
|
|
38
109
|
const uri = toFileUri(path, root);
|
|
39
110
|
await cslsp.ensureOpen(uri, readFileText(toFsPath(path, root)));
|
|
40
111
|
return uri;
|
|
41
112
|
};
|
|
42
113
|
const posSchema = {
|
|
43
114
|
path: z.string().describe("C# script path (res://..., absolute, or relative to the C# project root)"),
|
|
44
|
-
|
|
45
|
-
|
|
115
|
+
// .min(0): a negative line or character is not a position. The symptom here is
|
|
116
|
+
// NOT the GDScript one — measured against OmniSharp, a negative position does not
|
|
117
|
+
// come back a silent success, it comes back
|
|
118
|
+
// `LSP error [-32603]: Internal Error - System.ArgumentOutOfRangeException ...`
|
|
119
|
+
// with a .NET stack trace leaking into the tool's answer, on cs_hover,
|
|
120
|
+
// cs_definition, cs_references, cs_completion and cs_signature_help alike.
|
|
121
|
+
// A schema-legal call that can only ever produce an internal-error trace should
|
|
122
|
+
// be refused before it reaches the wire.
|
|
123
|
+
//
|
|
124
|
+
// NOTE the bound is one-sided and deliberately so: a line number PAST the end of
|
|
125
|
+
// the file produces the same -32603, and no schema can know the file's length.
|
|
126
|
+
// That case is out of scope here, not fixed by this.
|
|
127
|
+
line: z.number().int().min(0).describe("0-based line"),
|
|
128
|
+
character: z.number().int().min(0).describe("0-based character"),
|
|
46
129
|
};
|
|
47
130
|
server.registerTool("cs_completion", { title: "C# completion", description: "Type-aware C# code completion at a position via OmniSharp.", inputSchema: posSchema }, async ({ path, line, character }) => {
|
|
48
131
|
try {
|
|
@@ -98,15 +181,37 @@ export function registerCsLspTools(server, cslsp, cfg) {
|
|
|
98
181
|
});
|
|
99
182
|
server.registerTool("cs_rename", {
|
|
100
183
|
title: "C# rename symbol",
|
|
101
|
-
description: "Rename a C# symbol project-wide via OmniSharp. Returns the planned edit; pass apply=true to WRITE the changes to disk (DESTRUCTIVE — confirm with the user)."
|
|
184
|
+
description: "Rename a C# symbol project-wide via OmniSharp. Returns the planned edit; pass apply=true to WRITE the changes to disk (DESTRUCTIVE — confirm with the user). " +
|
|
185
|
+
"new_name must be a valid C# identifier and not a reserved keyword: the language server plans a rename to any string, including one that would write a file the C# compiler rejects, so an illegal name is refused here before the rename is planned. Verbatim identifiers (@class) and contextual keywords (var, value, async) are legal and accepted.",
|
|
102
186
|
inputSchema: {
|
|
103
187
|
...posSchema,
|
|
104
|
-
new_name: z.string().describe("New symbol name"),
|
|
188
|
+
new_name: z.string().describe("New symbol name — a valid C# identifier, optionally @-prefixed"),
|
|
105
189
|
apply: z.boolean().optional().describe("Write edits to disk (default false = dry run)"),
|
|
106
190
|
confirm: z.boolean().optional().describe("Auto-approve writing edits (skip the confirmation prompt); only relevant with apply=true"),
|
|
107
191
|
},
|
|
108
192
|
}, async ({ path, line, character, new_name, apply, confirm }) => {
|
|
109
193
|
try {
|
|
194
|
+
// Refuse BEFORE planning. OmniSharp does not validate the new name.
|
|
195
|
+
// Measured against a real OmniSharp v1.39.15 on the example-csharp fixture:
|
|
196
|
+
// a rename to "1bad name!", "class", "int", " ", "a\nb" or "my-name" each
|
|
197
|
+
// came back `isError: false` with a five-edit plan across Player.cs — and
|
|
198
|
+
// with apply:true the host WRITES those edits, producing C# that does not
|
|
199
|
+
// compile. The one string OmniSharp itself rejects is "", and it rejects it
|
|
200
|
+
// with an internal assertion failure ("Unexpected true - file Renamer.cs
|
|
201
|
+
// line 151"), not a usable validation error. So the refusal has to happen
|
|
202
|
+
// here for all of them, including "".
|
|
203
|
+
if (!CS_IDENTIFIER.test(new_name)) {
|
|
204
|
+
throw Object.assign(new Error(`Refusing to rename to ${JSON.stringify(new_name)}: it is not a valid C# ` +
|
|
205
|
+
`identifier (a letter or underscore, then letters, digits or underscores; ` +
|
|
206
|
+
`optionally @-prefixed). The language server will plan this rename anyway ` +
|
|
207
|
+
`and the written file would not compile.`), { refusal: true, code: "invalid_identifier" });
|
|
208
|
+
}
|
|
209
|
+
// The @ prefix is what MAKES a keyword legal, so it exempts the name here.
|
|
210
|
+
if (!new_name.startsWith("@") && CS_KEYWORDS.has(new_name)) {
|
|
211
|
+
throw Object.assign(new Error(`Refusing to rename to "${new_name}": it is a C# reserved keyword. The ` +
|
|
212
|
+
`language server will plan this rename anyway and the written file would ` +
|
|
213
|
+
`not compile. Pass "@${new_name}" if you meant the verbatim identifier.`), { refusal: true, code: "reserved_word" });
|
|
214
|
+
}
|
|
110
215
|
const uri = await openAndPos(path);
|
|
111
216
|
const edit = await cslsp.request("textDocument/rename", {
|
|
112
217
|
textDocument: { uri }, position: { line, character }, newName: new_name,
|