breakpoint-mcp 1.28.0 → 1.72.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/addon/breakpoint_mcp/operations.gd +248 -13
  3. package/addon/breakpoint_mcp/plugin.cfg +1 -1
  4. package/addon/breakpoint_mcp/runtime_bridge.gd +54 -1
  5. package/dist/annotations.js +3 -3
  6. package/dist/cli/doctor.js +1 -1
  7. package/dist/cli/init.js +1 -1
  8. package/dist/cli/tools.js +2 -2
  9. package/dist/csdap.js +88 -6
  10. package/dist/dap.js +172 -10
  11. package/dist/index.js +4 -4
  12. package/dist/path-cohort.js +177 -0
  13. package/dist/paths.js +111 -0
  14. package/dist/recipes.js +1 -1
  15. package/dist/schemas.js +49 -5
  16. package/dist/tools/assetgen.js +17 -1
  17. package/dist/tools/backend.js +18 -1
  18. package/dist/tools/cli.js +41 -4
  19. package/dist/tools/csdap.js +132 -8
  20. package/dist/tools/cslsp.js +110 -5
  21. package/dist/tools/dap.js +316 -59
  22. package/dist/tools/editor/audio.js +16 -2
  23. package/dist/tools/editor/common.js +39 -1
  24. package/dist/tools/editor/filesystem.js +23 -3
  25. package/dist/tools/editor/introspection.js +72 -1
  26. package/dist/tools/editor/node.js +7 -2
  27. package/dist/tools/editor/particles.js +7 -2
  28. package/dist/tools/editor/project_input_test.js +16 -2
  29. package/dist/tools/editor/resource.js +54 -7
  30. package/dist/tools/editor/scene.js +31 -4
  31. package/dist/tools/editor/shader.js +19 -3
  32. package/dist/tools/editor/spatial.js +16 -1
  33. package/dist/tools/editor/tiles.js +16 -1
  34. package/dist/tools/editor/ui.js +29 -2
  35. package/dist/tools/editor.js +20 -13
  36. package/dist/tools/knowledge.js +14 -1
  37. package/dist/tools/lsp-common.js +29 -0
  38. package/dist/tools/lsp.js +87 -8
  39. package/dist/tools/netcode.js +47 -4
  40. package/dist/tools/processes.js +14 -2
  41. package/dist/tools/runtime.js +49 -9
  42. package/dist/tools/tabletop.js +173 -7
  43. package/dist/tools/vcs.js +138 -22
  44. package/dist/toolsets.js +2 -2
  45. package/package.json +8 -2
package/dist/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
- scene_get_dependencies: { path: z.string(), dependencies: z.array(z.string()) },
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
- resource_set_import_settings: { path: z.string(), reimported: z.boolean(), settings: z.array(z.string()) },
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
- filesystem_move: { moved: z.string(), from: z.string(), moved_import: z.boolean() },
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: { session_id: z.string(), state: z.string(), scene: z.string() },
372
- dbg_attach: { session_id: z.string(), state: z.string() },
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(),
@@ -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;
@@ -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, _config) {
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
- }, async ({ scene, allow_port_conflict }) => {
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
- }, async ({ preset, output_path, debug, timeout_ms }, signal) => {
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
- }, async ({ script_path, args, timeout_ms }, signal) => {
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,
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { DapError } from "../dap.js";
3
- import { toFsPath } from "../paths.js";
3
+ import { resolveSourceFile } from "../paths.js";
4
4
  import { gate } from "../confirm.js";
5
5
  import { ok } from "./lsp-common.js";
6
6
  import { portFree, portConflictMessage } from "../ports.js";
@@ -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 [${e.command ?? "error"}]: ${e.message ?? String(err)}` }],
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
- process_id: z.number().int().describe("OS process id of the running .NET process to attach to"),
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 = toFsPath(path, cfg.csDapProjectPath);
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`). Requires a running debug session. Not gated (it only configures the debugger). " +
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) }))
@@ -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
- line: z.number().int().describe("0-based line"),
45
- character: z.number().int().describe("0-based character"),
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,