@el4cteo/rbx-studio-mcp 0.8.2 → 0.8.4

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 (43) hide show
  1. package/README.md +7 -4
  2. package/dist/bridge/api.js +2 -2
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +12 -4
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/harness.js +48 -6
  7. package/dist/bridge/harness.js.map +1 -1
  8. package/dist/bridge/remote.js +4 -2
  9. package/dist/bridge/remote.js.map +1 -1
  10. package/dist/bridge/rpc.js +43 -5
  11. package/dist/bridge/rpc.js.map +1 -1
  12. package/dist/bridge/server.js +26 -1
  13. package/dist/bridge/server.js.map +1 -1
  14. package/dist/lib/apidump.js +58 -13
  15. package/dist/lib/apidump.js.map +1 -1
  16. package/dist/lib/cloudassets.js +2 -2
  17. package/dist/lib/cloudassets.js.map +1 -1
  18. package/dist/lib/png.js +19 -31
  19. package/dist/lib/png.js.map +1 -1
  20. package/dist/tools/discover.js +78 -4
  21. package/dist/tools/discover.js.map +1 -1
  22. package/dist/tools/instances.js +3 -1
  23. package/dist/tools/instances.js.map +1 -1
  24. package/dist/tools/session.js +11 -3
  25. package/dist/tools/session.js.map +1 -1
  26. package/package.json +3 -3
  27. package/plugin/src/Commands.luau +72 -5
  28. package/plugin/src/Config.luau +65 -65
  29. package/plugin/src/Console.luau +5 -0
  30. package/plugin/src/Prompt.luau +23 -3
  31. package/plugin/src/handlers/Debug.luau +42 -0
  32. package/plugin/src/handlers/Exec.luau +5 -1
  33. package/plugin/src/handlers/Spatial.luau +34 -0
  34. package/plugin/src/handlers/Terrain.luau +10 -0
  35. package/plugin/src/handlers/Viewport.luau +33 -1
  36. package/plugin/src/handlers/World.luau +21 -1
  37. package/plugin/src/init.server.luau +25 -0
  38. package/scripts/check-plugin.mjs +54 -0
  39. package/scripts/test-apidump.mjs +170 -0
  40. package/scripts/test-bridge.mjs +111 -4
  41. package/scripts/test-live-tools.mjs +382 -0
  42. package/scripts/test-plugin.mjs +1 -0
  43. package/scripts/test-tools.mjs +44 -0
@@ -67,6 +67,20 @@ function Viewport.raycast(params: { [string]: any }): { [string]: any }
67
67
  local direction = toVector(params.direction, "direction")
68
68
  local length = tonumber(params.maxDistance) or DEFAULT_RAY_LENGTH
69
69
 
70
+ --[[
71
+ A zero direction has no `Unit` -- it is NaN -- and a ray along NaN hits
72
+ nothing, which the reply then reported as "nothing within 1000 studs". That
73
+ is a confident answer about empty space to a question that was never asked,
74
+ so the input is refused instead.
75
+ ]]
76
+ if direction.Magnitude == 0 then
77
+ Dispatch.fail(
78
+ "BAD_PARAMS",
79
+ "`direction` cannot be zero: a ray needs a way to point.",
80
+ 'Give a direction such as "0, -1, 0" for straight down.'
81
+ )
82
+ end
83
+
70
84
  local filter: { Instance } = {}
71
85
  for _, path in (params.ignore or {}) :: { string } do
72
86
  table.insert(filter, Paths.resolve(path))
@@ -225,7 +239,17 @@ function Viewport.focus(params: { [string]: any }): { [string]: any }
225
239
  -- with a flat surface where it would vanish to a line.
226
240
  local direction: Vector3
227
241
  if typeof(params.from) == "string" and params.from ~= "" then
228
- direction = toVector(params.from, "`from`").Unit
242
+ local requested = toVector(params.from, "`from`")
243
+ -- A zero vector has no direction: its `Unit` is NaN, so the eye position was
244
+ -- NaN and the camera was handed a CFrame that is not a place at all.
245
+ if requested.Magnitude == 0 then
246
+ Dispatch.fail(
247
+ "BAD_PARAMS",
248
+ "`from` cannot be zero: it is the direction to view the subject from.",
249
+ 'Give a direction such as "0, 1, 0" to look down from above.'
250
+ )
251
+ end
252
+ direction = requested.Unit
229
253
  else
230
254
  direction = Vector3.new(0.45, 0.35, 1).Unit
231
255
  end
@@ -278,6 +302,14 @@ function Viewport.camera(params: { [string]: any }): { [string]: any }
278
302
  local target = if params.lookAt ~= nil
279
303
  then toVector(params.lookAt, "`lookAt`")
280
304
  else eye + view.CFrame.LookVector
305
+ -- Aiming at where the camera already is has no direction to face.
306
+ if (target - eye).Magnitude == 0 then
307
+ Dispatch.fail(
308
+ "BAD_PARAMS",
309
+ "`position` and `lookAt` are the same point, so there is nothing to face.",
310
+ "Move `lookAt` to what the camera should be looking at."
311
+ )
312
+ end
281
313
  view.CFrame = CFrame.lookAt(eye, target)
282
314
  -- Same reason as focus: without this the rotation survives a frame.
283
315
  view.Focus = CFrame.new(target)
@@ -257,13 +257,33 @@ function World.collision(params: { [string]: any }): { [string]: any }
257
257
  way to remove it short of `execute_luau`. `UnregisterCollisionGroup`
258
258
  has existed the whole time; it was just never wired up.
259
259
  ]]
260
+ --[[
261
+ The engine unregisters a group that was never registered without a word,
262
+ so a misspelt name answered `removed = true` and removed nothing -- while
263
+ the group the caller meant stayed registered. Said plainly instead, and
264
+ not as an error: removing something already gone is a cleanup that
265
+ worked, and treating it as a failure would break every loop that ends
266
+ by tidying up.
267
+ ]]
268
+ local okRegistered, registered = pcall(function()
269
+ return world:IsCollisionGroupRegistered(name)
270
+ end)
271
+ if okRegistered and registered == false then
272
+ return {
273
+ group = name,
274
+ removed = false,
275
+ existed = false,
276
+ world = Paths.of(world),
277
+ note = 'No group by that name is registered, so nothing was removed. Check the spelling with action="list".',
278
+ }
279
+ end
260
280
  local ok, err = pcall(function()
261
281
  world:UnregisterCollisionGroup(name)
262
282
  end)
263
283
  if not ok then
264
284
  Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
265
285
  end
266
- return { group = name, removed = true, world = Paths.of(world) }
286
+ return { group = name, removed = true, existed = true, world = Paths.of(world) }
267
287
  end
268
288
 
269
289
  if action == "collidable" then
@@ -65,6 +65,9 @@ local SETTING_WIDGET_OPEN = "widgetOpen"
65
65
  ]]
66
66
  local SETTING_AUTO_OPEN = "autoOpen"
67
67
  local SETTING_PLAYTESTS = "playtestsEnabled"
68
+ -- Whether sentences typed at the prompt are sent to an agent. Absent means off:
69
+ -- it only turns on when the user runs `chat on`.
70
+ local SETTING_CHAT = "chatEnabled"
68
71
  local SETTING_WIDGET_SIZE = "widgetSize"
69
72
  local SETTING_WIDGET_TRACE = "widgetTrace"
70
73
 
@@ -261,6 +264,8 @@ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
261
264
  left exactly as it was, so turning this back on restores the panel to however
262
265
  it was last left rather than to a default.
263
266
  ]]
267
+ local chat = plugin:GetSetting(SETTING_CHAT) == true
268
+
264
269
  local storedAutoOpen = plugin:GetSetting(SETTING_AUTO_OPEN)
265
270
  local autoOpen = if typeof(storedAutoOpen) == "boolean" then storedAutoOpen else true
266
271
  if not autoOpen then
@@ -398,10 +403,21 @@ local function trace(what: string)
398
403
  end)
399
404
  end
400
405
 
406
+ --[[
407
+ Set the moment the plugin starts to unload.
408
+
409
+ `persistSize` checked a variable of this name that was never declared, so it
410
+ read as nil and the guard never applied. A widget that is being torn down
411
+ reports whatever size it collapses through, and only the minimum-size check
412
+ stood between that and the saved layout.
413
+ ]]
414
+ local unloading = false
415
+
401
416
  if RunService:IsEdit() then
402
417
  trace(string.format("load open=%s", tostring(storedOpen)))
403
418
 
404
419
  plugin.Unloading:Connect(function()
420
+ unloading = true
405
421
  trace("plugin.Unloading")
406
422
  end)
407
423
  widget.Destroying:Connect(function()
@@ -549,12 +565,21 @@ Commands.setup({
549
565
  autoOpen = value
550
566
  plugin:SetSetting(SETTING_AUTO_OPEN, value)
551
567
  end,
568
+ chat = function()
569
+ return chat
570
+ end,
571
+ saveChat = function(value)
572
+ chat = value
573
+ plugin:SetSetting(SETTING_CHAT, value)
574
+ end,
552
575
  status = function()
553
576
  return currentStatus
554
577
  end,
555
578
  studioId = studioId,
556
579
  })
557
580
 
581
+ Console.setPromptChat(chat)
582
+
558
583
  -- The saved preset, now that there is something to paint. Done immediately
559
584
  -- after mounting and before the first line is logged, so nothing is ever drawn
560
585
  -- in the wrong palette.
@@ -136,6 +136,60 @@ const kindless = new Set();
136
136
  }
137
137
  }
138
138
 
139
+ /*
140
+ * A module cloned into a client relay has to bring everything it requires.
141
+ *
142
+ * The relay is a LocalScript in the player's client VM, where the plugin's own
143
+ * module tree does not exist, so `require(script.Parent.X)` inside a cloned module
144
+ * finds only what was cloned beside it. ExecRuntime gained a `LogBuffer` require
145
+ * and the list of modules the exec relay clones did not, so every
146
+ * `execute_luau target="client"` failed with "Requested module experienced an
147
+ * error while loading" -- from a client VM nothing offline ever runs, and not
148
+ * noticed until a live playtest. Each clone list is checked here against the
149
+ * `require(script.Parent.X)` calls of the modules it names, transitively.
150
+ */
151
+ const missingClones = [];
152
+ {
153
+ const modules = new Map(
154
+ files.map((file) => [
155
+ file.replace(/\\/g, "/").replace(/^.*plugin\/src\//, "").replace(/\.luau$/, ""),
156
+ readFileSync(file, "utf8"),
157
+ ]),
158
+ );
159
+ const requiresOf = (name) =>
160
+ [...(modules.get(name) ?? "").matchAll(/require\(script\.Parent\.(\w+)\)/g)].map((m) => m[1]);
161
+
162
+ for (const [where, source] of modules) {
163
+ for (const list of source.matchAll(
164
+ /for _, name in \{([^}]*)\} do\s*local copy = script\.Parent\.Parent\[name\]:Clone\(\)/g,
165
+ )) {
166
+ const cloned = new Set([...list[1].matchAll(/"(\w+)"/g)].map((m) => m[1]));
167
+ const needed = new Set();
168
+ const pending = [...cloned];
169
+ while (pending.length > 0) {
170
+ for (const dependency of requiresOf(pending.pop())) {
171
+ if (!needed.has(dependency)) {
172
+ needed.add(dependency);
173
+ pending.push(dependency);
174
+ }
175
+ }
176
+ }
177
+ for (const dependency of needed) {
178
+ if (!cloned.has(dependency)) {
179
+ missingClones.push(`${where}: clones ${[...cloned].join(", ")} but they require ${dependency}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+ if (missingClones.length > 0) {
186
+ failures.push(
187
+ "A client relay clones modules that require others it does not clone, so the " +
188
+ "relay fails to load in the client VM:\n " +
189
+ missingClones.sort().join("\n "),
190
+ );
191
+ }
192
+
139
193
  if (unnamed.length > 0) {
140
194
  failures.push(
141
195
  "These operations have no entry in Phrase.luau, so the Studio panel shows " +
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The API dump: its cache, how a long-lived server keeps it current, and what
3
+ * the discovery tools say from it.
4
+ *
5
+ * Run against a tiny fixture in a private temp directory, so nothing here reads
6
+ * the real cache or touches the network -- and so it cannot pass or fail on
7
+ * whether Roblox happened to be reachable.
8
+ *
9
+ * Usage: node scripts/test-apidump.mjs
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // Redirected before anything is imported: the cache path is built from
17
+ // os.tmpdir() at call time, and this keeps every read and write in the sandbox.
18
+ const sandbox = mkdtempSync(join(tmpdir(), "rbx-apidump-test-"));
19
+ for (const name of ["TMPDIR", "TEMP", "TMP"]) process.env[name] = sandbox;
20
+ const cacheDir = join(sandbox, "roblox-studio-mcp");
21
+ const cacheFile = join(cacheDir, "api-dump.json");
22
+
23
+ const property = (name, type, extra = {}) => ({
24
+ MemberType: "Property",
25
+ Name: name,
26
+ ValueType: { Name: type, Category: "Primitive" },
27
+ ...extra,
28
+ });
29
+ const dumpOf = (extraClasses = []) => ({
30
+ Classes: [
31
+ { Name: "Instance", Superclass: "", Members: [property("Name", "string"), property("Archivable", "bool")] },
32
+ { Name: "Model", Superclass: "Instance", Members: [property("PrimaryPart", "BasePart")] },
33
+ {
34
+ Name: "Part",
35
+ Superclass: "Instance",
36
+ Members: [
37
+ property("Size", "Vector3"),
38
+ property("Anchored", "bool"),
39
+ // Above plugin identity: present in the dump, not reachable from here.
40
+ property("Secret", "string", { Security: { Read: "RobloxScriptSecurity", Write: "RobloxScriptSecurity" } }),
41
+ ],
42
+ },
43
+ ...extraClasses,
44
+ ],
45
+ Enums: [],
46
+ });
47
+ const widget = { Name: "Widget", Superclass: "Instance", Members: [property("Gizmo", "number")] };
48
+ const seed = (dump, fetchedAt = Date.now()) => {
49
+ mkdirSync(cacheDir, { recursive: true });
50
+ writeFileSync(cacheFile, JSON.stringify({ fetchedAt, dump }));
51
+ };
52
+ const until = async (test, what) => {
53
+ for (let attempt = 0; attempt < 100; attempt += 1) {
54
+ if (await test()) return;
55
+ await new Promise((resolve) => setTimeout(resolve, 20));
56
+ }
57
+ assert.fail(`timed out waiting for ${what}`);
58
+ };
59
+
60
+ const realFetch = globalThis.fetch;
61
+ const realNow = Date.now;
62
+ try {
63
+ // ---- what the dump answers ----------------------------------------------------
64
+ seed(dumpOf());
65
+ const dump = await import("../dist/lib/apidump.js");
66
+ const names = (await dump.propertiesOf("Part")).map((entry) => entry.name);
67
+ assert.deepEqual([...names].sort(), ["Anchored", "Archivable", "Name", "Size"], "own and inherited, nothing above plugin identity");
68
+ assert.ok((await dump.restrictionsOf("Part")).has("Secret"), "a restricted property is known about, separately");
69
+ assert.deepEqual(await dump.suggestProperty("Part", "size"), ["Size"], "a wrong-case name is suggested");
70
+ assert.deepEqual(await dump.suggestClass("Prat"), ["Part"], "a mistyped class is suggested");
71
+
72
+ // ---- what the tools say from it ----------------------------------------------------
73
+ const { z } = await import("zod");
74
+ const { registerDiscoverTools } = await import("../dist/tools/discover.js");
75
+ const registered = new Map();
76
+ const replies = {};
77
+ registerDiscoverTools({
78
+ server: { registerTool: (name, spec, handler) => registered.set(name, { spec, handler }) },
79
+ bridge: {
80
+ sessions: async () => ({ list: [], activeId: null, activeIsChosen: false }),
81
+ call: async (op) => replies[op],
82
+ notePlaceName: async () => {},
83
+ },
84
+ });
85
+ const run = (name, args) => {
86
+ const tool = registered.get(name);
87
+ return tool.handler(z.object(tool.spec.inputSchema).parse(args));
88
+ };
89
+ const textOf = (result) => result.content.map((part) => part.text ?? "").join("\n");
90
+
91
+ replies["discover.inspect"] = {
92
+ items: [{ path: "Workspace.P", className: "Part", childCount: 0, properties: { Name: "P", Size: "1, 1, 1" } }],
93
+ failures: [],
94
+ };
95
+ const inspected = textOf(await run("inspect", {
96
+ paths: ["Workspace.P"],
97
+ properties: ["Name", "Size", "Sizee", "Anchored", "Secret"],
98
+ }));
99
+ assert.match(inspected, /Requested but not returned:/);
100
+ assert.match(inspected, /Sizee \(not a property of Part; did you mean Size\?\)/, "a typo is named as one");
101
+ assert.match(inspected, /Anchored \(unset, or not readable in this session\)/, "a real property with no value is not called a typo");
102
+ assert.match(inspected, /Secret \(exists, but a plugin cannot read it\)/, "a restricted one says so");
103
+ assert.doesNotMatch(inspected.split("Requested but not returned:")[1], /\bName\b|\bSize \(/, "what did come back is not listed");
104
+
105
+ const complete = textOf(await run("inspect", { paths: ["Workspace.P"], properties: ["Name", "Size"] }));
106
+ assert.doesNotMatch(complete, /Requested but not returned/, "nothing to say when everything came back");
107
+
108
+ replies["discover.find"] = { items: [], total: 0, offset: 0, searched: 12 };
109
+ const misspelt = textOf(await run("find", { className: "Prat" }));
110
+ assert.match(misspelt, /"Prat" is not a Roblox class/);
111
+ assert.match(misspelt, /Did you mean: Part\?/);
112
+ const genuine = textOf(await run("find", { className: "Part" }));
113
+ assert.doesNotMatch(genuine, /is not a Roblox class/, "a real class that simply matched nothing is not accused");
114
+ const bare = textOf(await run("find", { nameContains: "zzz" }));
115
+ assert.doesNotMatch(bare, /is not a Roblox class/);
116
+
117
+ replies["discover.tree"] = { items: [], total: 0, offset: 0 };
118
+ assert.match(textOf(await run("tree", { className: "Prat" })), /"Prat" is not a Roblox class/);
119
+
120
+ // ---- a stale cache is served at once, then replaced in the running process ------------
121
+ // Each case gets its own copy of the module: the state under test is per process.
122
+ const fresh = (tag) => import(`../dist/lib/apidump.js?${tag}`);
123
+ const dayMs = 24 * 60 * 60 * 1000;
124
+ let downloads = 0;
125
+ const serve = (value) => {
126
+ globalThis.fetch = async () => {
127
+ downloads += 1;
128
+ return { ok: true, json: async () => value };
129
+ };
130
+ };
131
+
132
+ seed(dumpOf(), Date.now() - 3 * dayMs);
133
+ serve(dumpOf([widget]));
134
+ const stale = await fresh("stale");
135
+ assert.equal((await stale.propertiesOf("Widget")).length, 0, "the stale copy is what answers first");
136
+ await until(async () => (await stale.propertiesOf("Widget")).length > 0, "the refreshed dump to replace the stale one");
137
+ assert.equal(downloads, 1, "one download, however many calls were made meanwhile");
138
+ assert.ok((await stale.propertiesOf("Widget")).some((entry) => entry.name === "Gizmo"), "and the answers come from the new one");
139
+ assert.deepEqual(readdirSync(cacheDir), ["api-dump.json"], "the cache was replaced whole, leaving no staging file behind");
140
+ assert.ok(Date.now() - JSON.parse(readFileSync(cacheFile, "utf8")).fetchedAt < dayMs, "and is stamped as fresh");
141
+
142
+ // A fresh cache is not refreshed... until the process outlives it.
143
+ seed(dumpOf());
144
+ downloads = 0;
145
+ serve(dumpOf([widget]));
146
+ const longLived = await fresh("long-lived");
147
+ assert.equal((await longLived.propertiesOf("Widget")).length, 0);
148
+ assert.equal(downloads, 0, "a fresh cache needs no download");
149
+ Date.now = () => realNow() + 2 * dayMs;
150
+ await longLived.loadApiDump();
151
+ await until(async () => (await longLived.propertiesOf("Widget")).length > 0, "a process that outlived the cache to refresh it");
152
+ Date.now = realNow;
153
+ assert.equal(downloads, 1);
154
+
155
+ // A failed refresh leaves the old dump answering rather than emptying it.
156
+ seed(dumpOf(), Date.now() - 3 * dayMs);
157
+ globalThis.fetch = async () => {
158
+ throw new Error("offline");
159
+ };
160
+ const offline = await fresh("offline");
161
+ assert.ok((await offline.propertiesOf("Part")).length > 0, "no network, still answers from the stale copy");
162
+ await new Promise((resolve) => setTimeout(resolve, 50));
163
+ assert.ok((await offline.propertiesOf("Part")).length > 0, "and still does after the refresh failed");
164
+
165
+ process.stdout.write("apidump: ok\n");
166
+ } finally {
167
+ globalThis.fetch = realFetch;
168
+ Date.now = realNow;
169
+ rmSync(sandbox, { recursive: true, force: true });
170
+ }
@@ -273,10 +273,16 @@ function twoStudios() {
273
273
  bridge.setActiveForAll("studio-b");
274
274
  const late = new LocalBridge(bridge);
275
275
  assert.equal((await late.sessions()).activeId, "studio-b");
276
- await late.call("studio.ping", {}, { timeoutMs: 50 }).catch((cause) => {
277
- assert.notEqual(cause.code, "AMBIGUOUS_STUDIO", "the user's pick routes the call");
278
- });
276
+ const routed = late.call("studio.ping", {}, { timeoutMs: 50 });
277
+ // Read while the call is outstanding: once it times out the command is taken
278
+ // back off the queue, so the queue is only evidence of routing until then.
279
279
  assert.equal(bridge.sessions.get("studio-b").queue.length, 1, "the call went to the pick");
280
+ assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "and not to the other one");
281
+ await assert.rejects(
282
+ routed,
283
+ (cause) => cause.code === "TIMEOUT",
284
+ "the user's pick routes the call, so it fails by timing out and not as ambiguous",
285
+ );
280
286
 
281
287
  await assert.rejects(
282
288
  async () => late.call("studio.ping", {}, { studioId: "gone", timeoutMs: 50 }),
@@ -285,6 +291,83 @@ function twoStudios() {
285
291
  );
286
292
  }
287
293
 
294
+ /**
295
+ * A call that has timed out must not be run afterwards.
296
+ *
297
+ * The timeout answered the agent with a failure and dropped the request from the
298
+ * in-flight table, but left the command on the queue. The plugin's next poll --
299
+ * or the next stream to connect -- then picked it up and ran it: a create or a
300
+ * delete nobody was waiting for any more, usually after the agent had retried it.
301
+ */
302
+ {
303
+ const bridge = new Bridge();
304
+ bridge.attach(identity("studio-a", 111), null);
305
+
306
+ await assert.rejects(
307
+ bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 20 }),
308
+ (cause) => cause.code === "TIMEOUT" && /never reached Studio/.test(cause.message),
309
+ "nothing collected it, and the error says so",
310
+ );
311
+ assert.equal(bridge.sessions.get("studio-a").queue.length, 0, "the abandoned command is gone");
312
+
313
+ // A poll arriving now has nothing to take, rather than the command that failed.
314
+ const held = new AbortController();
315
+ const waiting = bridge.waitForCommand("studio-a", held.signal);
316
+ setTimeout(() => held.abort(), 20);
317
+ assert.equal(await waiting, null, "a later poll is not handed the dead command");
318
+
319
+ // The same for a stream that connects after the deadline: nothing to flush.
320
+ const written = [];
321
+ const stream = {
322
+ writableEnded: false,
323
+ write(chunk) {
324
+ written.push(chunk);
325
+ return true;
326
+ },
327
+ end() {
328
+ this.writableEnded = true;
329
+ },
330
+ };
331
+ bridge.attach(identity("studio-a", 111), stream);
332
+ assert.deepEqual(written, [], "a reconnecting stream is not sent the dead command");
333
+
334
+ // And a live one is still delivered, so the queue is not simply being dropped.
335
+ const live = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: 1000 });
336
+ assert.equal(written.length, 1, "a command issued to an open stream is written at once");
337
+ const frame = JSON.parse(written[0].replace(/^data: /, ""));
338
+ bridge.settle("studio-a", { id: frame.id, ok: true, data: "pong" });
339
+ assert.equal(await live, "pong");
340
+ }
341
+
342
+ /**
343
+ * `call` returns a promise, so every way it can fail is a rejection.
344
+ *
345
+ * NO_STUDIO and UNKNOWN_STUDIO used to be thrown from inside `call`, before a
346
+ * promise existed. `bridge.call(...).catch(...)` -- how `playtest stop` shrugs
347
+ * off a session that closed under it -- never got the chance to catch them.
348
+ */
349
+ {
350
+ const bridge = new Bridge();
351
+ let pending;
352
+ assert.doesNotThrow(() => {
353
+ pending = bridge.call("studio.ping", {}, { clientId: "c" });
354
+ }, "no Studio connected does not throw synchronously");
355
+ await assert.rejects(pending, (cause) => cause.code === "NO_STUDIO");
356
+
357
+ bridge.attach(identity("studio-a", 111), null);
358
+ assert.doesNotThrow(() => {
359
+ pending = bridge.call("studio.ping", {}, { clientId: "c", timeoutMs: -1 });
360
+ }, "a bad timeout does not throw synchronously");
361
+ await assert.rejects(pending, (cause) => cause.code === "BAD_TIMEOUT");
362
+
363
+ const local = new LocalBridge(bridge);
364
+ let caught = false;
365
+ await local.call("studio.ping", {}, { studioId: "gone" }).catch(() => {
366
+ caught = true;
367
+ });
368
+ assert.equal(caught, true, "a `.catch` on the local bridge sees an unknown Studio");
369
+ }
370
+
288
371
  // A stream that dies without /bye (a crashed Studio) removes its session, and a
289
372
  // replaced stream closing late does not remove the session that replaced it.
290
373
  {
@@ -314,6 +397,28 @@ function twoStudios() {
314
397
  .list.length;
315
398
  const settle = () => new Promise((resolve) => setTimeout(resolve, 250));
316
399
 
400
+ // A page that rebinds its own hostname to 127.0.0.1 is same-origin as far as
401
+ // the browser is concerned: it can send the custom header without a preflight,
402
+ // and a same-origin GET carries no Origin to reject. What it cannot do is make
403
+ // the Host header say 127.0.0.1, so that is what is checked.
404
+ const statusFor = (host) =>
405
+ new Promise((resolve, reject) => {
406
+ const req = request(
407
+ { host: "127.0.0.1", port, path: "/sessions", method: "GET", headers: { host, "x-roblox-studio-mcp": "test" } },
408
+ (res) => {
409
+ res.resume();
410
+ resolve(res.statusCode);
411
+ },
412
+ );
413
+ req.on("error", reject);
414
+ req.end();
415
+ });
416
+ assert.equal(await statusFor(`127.0.0.1:${port}`), 200, "the address the plugin uses is served");
417
+ assert.equal(await statusFor(`localhost:${port}`), 200, "and so is localhost");
418
+ assert.equal(await statusFor(`evil.example:${port}`), 403, "a rebound hostname is refused");
419
+ assert.equal(await statusFor(`127.0.0.1.evil.example:${port}`), 403, "even one that merely starts like loopback");
420
+ assert.equal(await statusFor("127.0.0.1"), 403, "and a Host that names no port is not this server");
421
+
317
422
  const first = await open();
318
423
  const second = await open();
319
424
  first.destroy();
@@ -432,7 +537,9 @@ function twoStudios() {
432
537
  }
433
538
  const before = delays.length;
434
539
  for (const timeoutMs of [NaN, Infinity, -Infinity, -1, 0, null, "1000", {}, 2 ** 31, Number.MAX_SAFE_INTEGER]) {
435
- assert.throws(() => bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
540
+ // A rejection rather than a throw: `call` returns a promise, so a caller's
541
+ // `.catch` has to be able to see this one too.
542
+ await assert.rejects(bridge.call("studio.ping", {}, {clientId:"test",timeoutMs}), {code:"BAD_TIMEOUT"});
436
543
  }
437
544
  assert.equal(delays.length, before, "invalid deadlines never create timers");
438
545
  assert.equal(bridge.sessions.get("timeout-test").pending.size, 0);