@quenty/studio-bridge 0.23.0 → 0.24.0

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 (231) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +2 -2
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
@@ -0,0 +1,21 @@
1
+ # The planted playtest bridge
2
+
3
+ With Studio's **Load user plugins on playtest** off, the studio-bridge plugin never loads into a playtest. So `startPlaytest` plants the same client (`templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua`) into the edit place's ServerScriptService. The playtest copies the edit place, so the client starts in the playtest's server as an ordinary Script and connects as its server session. In play mode it also plants the client's twin as a LocalScript in StarterPlayerScripts, which every player's client runs and connects as its client session; a LocalScript there can open the WebSocket to the bridge and read the test args, measured in a Studio playtest client. With the setting on, nothing is planted and the plugin is the playtest's sessions.
4
+
5
+ ## What an ordinary playtest Script can do
6
+
7
+ These limits shape the design. They were measured in a Studio playtest server:
8
+
9
+ | Capability | Result |
10
+ |---|---|
11
+ | WebSocket to the bridge on localhost (`CreateWebStreamClient`) | Works, even with Allow HTTP Requests off |
12
+ | `HttpService:GetAsync` to localhost | Refused, so the port comes from the StudioTestService test args instead of a scan |
13
+ | `loadstring` | Off. `LoadStringEnabled` isn't scriptable, not even from a plugin |
14
+ | Setting `ModuleScript.Source` | Needs the `PluginOrOpenCloud` capability |
15
+ | `SerializationService` | `GetService` returns `nil` |
16
+
17
+ So the planted client can't compile code. The edit session compiles everything ahead of time: each action, and any script `playtest run` will send, becomes a ModuleScript returning its function and its own source text. The router finds a source by its exact text (`PrecompiledSources.lua`, built by `src/plugin/playtest-bridge.ts`). New code sent to a planted session fails and asks for the setting. Privileged compiling, for example through Studio's MCP `execute_luau`, would plug in at `ActionRouter:SetCompiler`.
18
+
19
+ A precompiled source that fails to load is skipped, which would hide its compile error. So `startPlaytest` compiles every one in the edit session before planting.
20
+
21
+ `LogService.MessageOut` fires a frame or two after a `print` in a playtest server, later than in a plugin. That's why `execute.lua` waits several frames before it stops capturing output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/studio-bridge",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "WebSocket-based bridge for running Luau scripts in Roblox Studio",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -41,5 +41,5 @@
41
41
  "test:watch": "vitest",
42
42
  "test:integration": "node dist/test/integration/smoke-test.js"
43
43
  },
44
- "gitHead": "2cc8ce7136d877fc883c13bc5b174693fd7059be"
44
+ "gitHead": "2d4d5cbd9339e7be7e36d30aa91f7146225cb500"
45
45
  }
@@ -687,6 +687,7 @@ export class BridgeConnection extends EventEmitter {
687
687
  origin: info.launchId ? 'managed' : 'user',
688
688
  launchId: info.launchId,
689
689
  playerName: info.playerName,
690
+ playtestId: info.playtestId,
690
691
  context,
691
692
  instanceId: info.instanceId ?? info.sessionId,
692
693
  placeId: 0,
@@ -162,6 +162,57 @@ describe('BridgeSession', () => {
162
162
  expect(timeout).toBe(10_000);
163
163
  });
164
164
 
165
+ it('sends inputs base64-encoded', async () => {
166
+ const handle = new MockTransportHandle();
167
+ handle.sendActionAsync.mockResolvedValueOnce({
168
+ type: 'scriptComplete',
169
+ sessionId: 'session-1',
170
+ payload: { success: true },
171
+ });
172
+
173
+ const session = new BridgeSession(createSessionInfo(), handle);
174
+ await session.execAsync('return ...', undefined, undefined, [
175
+ Buffer.from('hello'),
176
+ ]);
177
+
178
+ const [msg] = handle.sendActionAsync.mock.calls[0];
179
+ expect((msg as any).payload.inputs).toEqual(['aGVsbG8=']);
180
+ });
181
+
182
+ it('sends --run-as and carries back where the code ran', async () => {
183
+ const handle = new MockTransportHandle();
184
+ handle.sendActionAsync.mockResolvedValueOnce({
185
+ type: 'scriptComplete',
186
+ sessionId: 'session-1',
187
+ payload: { success: true, ranAs: 'game' },
188
+ });
189
+
190
+ const session = new BridgeSession(createSessionInfo(), handle);
191
+ const result = await session.execAsync(
192
+ 'return 1',
193
+ undefined,
194
+ undefined,
195
+ undefined,
196
+ 'game'
197
+ );
198
+
199
+ const [msg] = handle.sendActionAsync.mock.calls[0];
200
+ expect((msg as any).payload.runAs).toBe('game');
201
+ expect(result.ranAs).toBe('game');
202
+ });
203
+
204
+ it('refuses inputs over the size limit without sending them', async () => {
205
+ const handle = new MockTransportHandle();
206
+ const session = new BridgeSession(createSessionInfo(), handle);
207
+
208
+ await expect(
209
+ session.execAsync('return ...', undefined, undefined, [
210
+ Buffer.alloc(192 * 1024 * 1024 + 1),
211
+ ])
212
+ ).rejects.toThrow('that can be sent to Studio with a script');
213
+ expect(handle.sendActionAsync).not.toHaveBeenCalled();
214
+ });
215
+
165
216
  it('returns error info from error response', async () => {
166
217
  const handle = new MockTransportHandle();
167
218
  handle.sendActionAsync.mockResolvedValueOnce({
@@ -41,6 +41,17 @@ import {
41
41
  type ActionSource,
42
42
  } from '../commands/framework/action-loader.js';
43
43
  import { OutputHelper } from '@quenty/cli-output-helpers';
44
+ import {
45
+ MAX_EXECUTE_INPUT_BYTES,
46
+ type PlaytestBridgePayload,
47
+ type PlaytestPlayerPayload,
48
+ type PlaytestPlayersRequest,
49
+ } from '../server/web-socket-protocol.js';
50
+
51
+ export interface PlaytestPlayersResult {
52
+ players: PlaytestPlayerPayload[];
53
+ remaining: string[];
54
+ }
44
55
 
45
56
  const EXECUTE_REPLY_GRACE_MS = 5_000;
46
57
 
@@ -57,18 +68,6 @@ const DEFAULT_TIMEOUTS: Record<string, number> = {
57
68
  endPlaytest: 5_000,
58
69
  };
59
70
 
60
- /** Time a playtest gets to start and stop on top of its script's own timeout. */
61
- const PLAYTEST_STARTUP_ALLOWANCE_MS = 60_000;
62
-
63
- export interface RunPlaytestScriptOptions {
64
- mode: PlaytestMode;
65
- script: string;
66
- runnerSource: string;
67
- timeoutSec: number;
68
- /** Passed to the script as `...`. */
69
- args?: string[];
70
- }
71
-
72
71
  /** Map an `execute`-style reply onto an ExecResult. */
73
72
  function toExecResult(result: PluginMessage): ExecResult {
74
73
  if (result.type === 'scriptComplete') {
@@ -82,6 +81,7 @@ function toExecResult(result: PluginMessage): ExecResult {
82
81
  error: result.payload.error,
83
82
  traceback: result.payload.traceback,
84
83
  returnValues: result.payload.returnValues,
84
+ ...(result.payload.ranAs ? { ranAs: result.payload.ranAs } : {}),
85
85
  };
86
86
  }
87
87
 
@@ -156,8 +156,19 @@ export class BridgeSession extends EventEmitter {
156
156
  async execAsync(
157
157
  code: string,
158
158
  timeout?: number,
159
- args?: string[]
159
+ args?: string[],
160
+ inputs?: Buffer[],
161
+ runAs?: 'plugin' | 'game'
160
162
  ): Promise<ExecResult> {
163
+ const inputBytes = (inputs ?? []).reduce((sum, b) => sum + b.length, 0);
164
+ if (inputBytes > MAX_EXECUTE_INPUT_BYTES) {
165
+ throw new Error(
166
+ `The inputs are ${_formatMiB(inputBytes)}, over the ${_formatMiB(
167
+ MAX_EXECUTE_INPUT_BYTES
168
+ )} that can be sent to Studio with a script`
169
+ );
170
+ }
171
+
161
172
  this._assertConnected();
162
173
  await this._ensureActionsAsync();
163
174
 
@@ -173,42 +184,13 @@ export class BridgeSession extends EventEmitter {
173
184
  script: code,
174
185
  timeoutMs,
175
186
  ...(args && args.length > 0 ? { args } : {}),
176
- },
177
- },
178
- timeoutMs + EXECUTE_REPLY_GRACE_MS
179
- );
180
-
181
- return toExecResult(result);
182
- }
183
-
184
- /**
185
- * Run a script in a fresh playtest's server, started from this edit
186
- * session, and resolve with its result once the playtest has ended. Works
187
- * without the playtest's own sessions connecting: the plugin plants
188
- * `runnerSource` in the playtest to run the script and end the playtest.
189
- */
190
- async runPlaytestScriptAsync(
191
- options: RunPlaytestScriptOptions
192
- ): Promise<ExecResult> {
193
- this._assertConnected();
194
- await this._ensureActionsAsync();
195
-
196
- const result = await this._handle.sendActionAsync<PluginMessage>(
197
- {
198
- type: 'runPlaytestScript',
199
- sessionId: this._info.sessionId,
200
- requestId: randomUUID(),
201
- payload: {
202
- mode: options.mode,
203
- script: options.script,
204
- runnerSource: options.runnerSource,
205
- timeoutSec: options.timeoutSec,
206
- ...(options.args && options.args.length > 0
207
- ? { args: options.args }
187
+ ...(inputs && inputs.length > 0
188
+ ? { inputs: inputs.map((input) => input.toString('base64')) }
208
189
  : {}),
190
+ ...(runAs ? { runAs } : {}),
209
191
  },
210
192
  },
211
- options.timeoutSec * 1000 + PLAYTEST_STARTUP_ALLOWANCE_MS
193
+ timeoutMs + EXECUTE_REPLY_GRACE_MS
212
194
  );
213
195
 
214
196
  return toExecResult(result);
@@ -442,7 +424,8 @@ export class BridgeSession extends EventEmitter {
442
424
  playtestId: string,
443
425
  bridgePort: number,
444
426
  timeoutMs: number,
445
- players?: number
427
+ players?: number,
428
+ bridge?: PlaytestBridgePayload
446
429
  ): Promise<PlaytestEndResult> {
447
430
  this._assertConnected();
448
431
  await this._ensureActionsAsync();
@@ -458,6 +441,7 @@ export class BridgeSession extends EventEmitter {
458
441
  bridgePort,
459
442
  instanceId: this._info.instanceId,
460
443
  ...(players !== undefined ? { players } : {}),
444
+ ...(bridge ? { bridge } : {}),
461
445
  },
462
446
  },
463
447
  timeoutMs
@@ -500,6 +484,37 @@ export class BridgeSession extends EventEmitter {
500
484
  throw pluginError('endPlaytestResult', result);
501
485
  }
502
486
 
487
+ /**
488
+ * Read or change the players of the playtest this session belongs to.
489
+ * `timeoutMs` bounds the whole request, including any wait in the plugin.
490
+ */
491
+ async playtestPlayersAsync(
492
+ request: PlaytestPlayersRequest,
493
+ timeoutMs: number
494
+ ): Promise<PlaytestPlayersResult> {
495
+ this._assertConnected();
496
+ await this._ensureActionsAsync();
497
+
498
+ const result = await this._handle.sendActionAsync<PluginMessage>(
499
+ {
500
+ type: 'playtestPlayers',
501
+ sessionId: this._info.sessionId,
502
+ requestId: randomUUID(),
503
+ payload: request,
504
+ },
505
+ timeoutMs
506
+ );
507
+
508
+ if (result.type === 'playtestPlayersResult') {
509
+ return {
510
+ players: result.payload.players ?? [],
511
+ remaining: result.payload.remaining ?? [],
512
+ };
513
+ }
514
+
515
+ throw pluginError('playtestPlayersResult', result);
516
+ }
517
+
503
518
  private _assertConnected(): void {
504
519
  if (!this._handle.isConnected) {
505
520
  throw new SessionDisconnectedError(this._info.sessionId);
@@ -609,3 +624,7 @@ export class BridgeSession extends EventEmitter {
609
624
  OutputHelper.verbose('[actions] Action sync complete');
610
625
  }
611
626
  }
627
+
628
+ function _formatMiB(bytes: number): string {
629
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
630
+ }
@@ -51,6 +51,8 @@ export interface PluginSessionInfo {
51
51
  launchId?: string;
52
52
  /** Player name from register, for a multiplayer test client. */
53
53
  playerName?: string;
54
+ /** Playtest id from register, for a playtest studio-bridge started. */
55
+ playtestId?: string;
54
56
  }
55
57
 
56
58
  const SHUTDOWN_TIMEOUT_MS = 2_000;
@@ -427,6 +429,7 @@ export class BridgeHost extends EventEmitter {
427
429
  placeFile: msg.payload.placeFile,
428
430
  launchId: msg.payload.launchId,
429
431
  playerName: msg.payload.playerName,
432
+ playtestId: msg.payload.playtestId,
430
433
  });
431
434
  };
432
435
 
@@ -255,6 +255,13 @@ const ServerMessageSchema = z.discriminatedUnion('type', [
255
255
  bridgePort: z.number(),
256
256
  instanceId: z.string(),
257
257
  players: z.number().int().min(1).optional(),
258
+ bridge: z
259
+ .object({
260
+ entrySource: z.string(),
261
+ modules: z.record(z.string(), z.string()),
262
+ precompiled: z.array(z.string()),
263
+ })
264
+ .optional(),
258
265
  })
259
266
  .loose(),
260
267
  }),
@@ -265,18 +272,24 @@ const ServerMessageSchema = z.discriminatedUnion('type', [
265
272
  payload: z.object({}).loose(),
266
273
  }),
267
274
  z.object({
268
- type: z.literal('runPlaytestScript'),
275
+ type: z.literal('playtestPlayers'),
269
276
  sessionId: z.string(),
270
277
  requestId: z.string(),
271
- payload: z
272
- .object({
273
- mode: z.enum(['play', 'run']),
274
- script: z.string(),
275
- runnerSource: z.string(),
278
+ payload: z.discriminatedUnion('op', [
279
+ z.object({ op: z.literal('list') }),
280
+ z.object({
281
+ op: z.literal('add'),
282
+ count: z.number().int().min(1),
276
283
  timeoutSec: z.number(),
277
- args: z.array(z.string()).optional(),
278
- })
279
- .loose(),
284
+ }),
285
+ z.object({
286
+ op: z.literal('remove'),
287
+ kick: z.array(z.string()),
288
+ names: z.array(z.string()),
289
+ timeoutSec: z.number(),
290
+ }),
291
+ z.object({ op: z.literal('leave') }),
292
+ ]),
280
293
  }),
281
294
  z.object({
282
295
  type: z.literal('error'),
@@ -13,6 +13,7 @@ import type {
13
13
  OutputLevel,
14
14
  SerializedReturnValue,
15
15
  PlaytestMode,
16
+ RunAs,
16
17
  } from '../server/web-socket-protocol.js';
17
18
 
18
19
  // Re-export protocol types used in the public API
@@ -23,6 +24,7 @@ export type {
23
24
  OutputLevel,
24
25
  SerializedReturnValue,
25
26
  PlaytestMode,
27
+ RunAs,
26
28
  };
27
29
 
28
30
  export type SessionContext = 'edit' | 'client' | 'server';
@@ -41,6 +43,8 @@ export interface SessionInfo {
41
43
  launchId?: string;
42
44
  /** The player of a multiplayer test client session. */
43
45
  playerName?: string;
46
+ /** The playtest studio-bridge started this session's DataModel for. */
47
+ playtestId?: string;
44
48
  context: SessionContext;
45
49
  instanceId: string;
46
50
  placeId: number;
@@ -67,6 +71,7 @@ export interface ExecResult {
67
71
  * no return channel at all (a run that never reached a return) — distinct from `[]`, which means it returned nothing.
68
72
  */
69
73
  returnValues?: SerializedReturnValue[];
74
+ ranAs?: RunAs;
70
75
  }
71
76
 
72
77
  export interface StateResult {
@@ -6,6 +6,7 @@ import { describe, it, expect, vi } from 'vitest';
6
6
  import {
7
7
  execCommand,
8
8
  execHandlerAsync,
9
+ formatExecText,
9
10
  runHandlerAsync,
10
11
  type ExecResult,
11
12
  } from './exec.js';
@@ -15,14 +16,25 @@ vi.mock('fs/promises', () => ({
15
16
  readFile: vi.fn(),
16
17
  }));
17
18
 
19
+ vi.mock('../../../roblox-mcp/index.js', () => ({
20
+ tryExecuteLuauAsync: vi.fn(),
21
+ }));
22
+
18
23
  import * as fs from 'fs/promises';
24
+ import { tryExecuteLuauAsync } from '../../../roblox-mcp/index.js';
19
25
 
20
26
  function createMockSession(execResult: {
21
27
  success: boolean;
22
28
  output: Array<{ level: string; body: string }>;
23
29
  error?: string;
30
+ ranAs?: string;
24
31
  }) {
25
32
  return {
33
+ info: {
34
+ sessionId: 'place1-aaaa-edit',
35
+ instanceId: 'place1-aaaa',
36
+ context: 'edit',
37
+ },
26
38
  execAsync: vi.fn().mockResolvedValue(execResult),
27
39
  } as any;
28
40
  }
@@ -96,6 +108,88 @@ describe('execHandlerAsync', () => {
96
108
  ]);
97
109
  });
98
110
 
111
+ it('forwards inputs to session.execAsync', async () => {
112
+ const session = createMockSession({
113
+ success: true,
114
+ output: [],
115
+ });
116
+ const input = Buffer.from('hello');
117
+
118
+ await execHandlerAsync(session, {
119
+ scriptContent: 'return ...',
120
+ args: ['Player1'],
121
+ inputs: [input],
122
+ });
123
+
124
+ expect(session.execAsync).toHaveBeenCalledWith(
125
+ 'return ...',
126
+ undefined,
127
+ ['Player1'],
128
+ [input]
129
+ );
130
+ });
131
+
132
+ it('forwards --run-as and reports where the code ran', async () => {
133
+ const session = createMockSession({
134
+ success: true,
135
+ output: [],
136
+ ranAs: 'game',
137
+ });
138
+
139
+ const result = await execHandlerAsync(session, {
140
+ scriptContent: 'return 1',
141
+ runAs: 'game',
142
+ });
143
+
144
+ expect(session.execAsync).toHaveBeenCalledWith(
145
+ 'return 1',
146
+ undefined,
147
+ undefined,
148
+ undefined,
149
+ 'game'
150
+ );
151
+ expect(result.ranAs).toBe('game');
152
+ expect(formatExecText({ ...result, output: [] })).toContain(
153
+ '(ran as game)'
154
+ );
155
+ });
156
+
157
+ it('runs --run-as mcp through Studio MCP, targeting the session Studio', async () => {
158
+ const session = createMockSession({ success: true, output: [] });
159
+ vi.mocked(tryExecuteLuauAsync).mockResolvedValueOnce({
160
+ success: true,
161
+ value: '42',
162
+ });
163
+
164
+ const result = await execHandlerAsync(session, {
165
+ scriptContent: 'return 42',
166
+ runAs: 'mcp',
167
+ });
168
+
169
+ expect(session.execAsync).not.toHaveBeenCalled();
170
+ expect(tryExecuteLuauAsync).toHaveBeenCalledWith(
171
+ 'return 42',
172
+ expect.objectContaining({ target: session.info.instanceId })
173
+ );
174
+ expect(result).toMatchObject({
175
+ success: true,
176
+ returnValues: ['42'],
177
+ ranAs: 'mcp',
178
+ });
179
+ });
180
+
181
+ it('refuses args with --run-as mcp, which only takes the code', async () => {
182
+ const session = createMockSession({ success: true, output: [] });
183
+
184
+ await expect(
185
+ execHandlerAsync(session, {
186
+ scriptContent: 'return ...',
187
+ args: ['a'],
188
+ runAs: 'mcp',
189
+ })
190
+ ).rejects.toThrow("--run-as mcp can't pass args");
191
+ });
192
+
99
193
  it('passes undefined timeout when not specified', async () => {
100
194
  const session = createMockSession({
101
195
  success: true,