mstro-app 0.1.47

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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/bin/commands/config.js +145 -0
  4. package/bin/commands/login.js +313 -0
  5. package/bin/commands/logout.js +75 -0
  6. package/bin/commands/status.js +197 -0
  7. package/bin/commands/whoami.js +161 -0
  8. package/bin/configure-claude.js +298 -0
  9. package/bin/mstro.js +581 -0
  10. package/bin/postinstall.js +45 -0
  11. package/bin/release.sh +110 -0
  12. package/dist/server/cli/headless/claude-invoker.d.ts +17 -0
  13. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -0
  14. package/dist/server/cli/headless/claude-invoker.js +311 -0
  15. package/dist/server/cli/headless/claude-invoker.js.map +1 -0
  16. package/dist/server/cli/headless/index.d.ts +13 -0
  17. package/dist/server/cli/headless/index.d.ts.map +1 -0
  18. package/dist/server/cli/headless/index.js +10 -0
  19. package/dist/server/cli/headless/index.js.map +1 -0
  20. package/dist/server/cli/headless/mcp-config.d.ts +11 -0
  21. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -0
  22. package/dist/server/cli/headless/mcp-config.js +76 -0
  23. package/dist/server/cli/headless/mcp-config.js.map +1 -0
  24. package/dist/server/cli/headless/output-utils.d.ts +33 -0
  25. package/dist/server/cli/headless/output-utils.d.ts.map +1 -0
  26. package/dist/server/cli/headless/output-utils.js +101 -0
  27. package/dist/server/cli/headless/output-utils.js.map +1 -0
  28. package/dist/server/cli/headless/prompt-utils.d.ts +21 -0
  29. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -0
  30. package/dist/server/cli/headless/prompt-utils.js +84 -0
  31. package/dist/server/cli/headless/prompt-utils.js.map +1 -0
  32. package/dist/server/cli/headless/runner.d.ts +24 -0
  33. package/dist/server/cli/headless/runner.d.ts.map +1 -0
  34. package/dist/server/cli/headless/runner.js +99 -0
  35. package/dist/server/cli/headless/runner.js.map +1 -0
  36. package/dist/server/cli/headless/types.d.ts +106 -0
  37. package/dist/server/cli/headless/types.d.ts.map +1 -0
  38. package/dist/server/cli/headless/types.js +4 -0
  39. package/dist/server/cli/headless/types.js.map +1 -0
  40. package/dist/server/cli/improvisation-session-manager.d.ts +155 -0
  41. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -0
  42. package/dist/server/cli/improvisation-session-manager.js +415 -0
  43. package/dist/server/cli/improvisation-session-manager.js.map +1 -0
  44. package/dist/server/index.d.ts +2 -0
  45. package/dist/server/index.d.ts.map +1 -0
  46. package/dist/server/index.js +386 -0
  47. package/dist/server/index.js.map +1 -0
  48. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  49. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  50. package/dist/server/mcp/bouncer-cli.js +99 -0
  51. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  52. package/dist/server/mcp/bouncer-integration.d.ts +36 -0
  53. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -0
  54. package/dist/server/mcp/bouncer-integration.js +301 -0
  55. package/dist/server/mcp/bouncer-integration.js.map +1 -0
  56. package/dist/server/mcp/security-audit.d.ts +52 -0
  57. package/dist/server/mcp/security-audit.d.ts.map +1 -0
  58. package/dist/server/mcp/security-audit.js +118 -0
  59. package/dist/server/mcp/security-audit.js.map +1 -0
  60. package/dist/server/mcp/security-patterns.d.ts +73 -0
  61. package/dist/server/mcp/security-patterns.d.ts.map +1 -0
  62. package/dist/server/mcp/security-patterns.js +247 -0
  63. package/dist/server/mcp/security-patterns.js.map +1 -0
  64. package/dist/server/mcp/server.d.ts +3 -0
  65. package/dist/server/mcp/server.d.ts.map +1 -0
  66. package/dist/server/mcp/server.js +146 -0
  67. package/dist/server/mcp/server.js.map +1 -0
  68. package/dist/server/routes/files.d.ts +9 -0
  69. package/dist/server/routes/files.d.ts.map +1 -0
  70. package/dist/server/routes/files.js +24 -0
  71. package/dist/server/routes/files.js.map +1 -0
  72. package/dist/server/routes/improvise.d.ts +3 -0
  73. package/dist/server/routes/improvise.d.ts.map +1 -0
  74. package/dist/server/routes/improvise.js +72 -0
  75. package/dist/server/routes/improvise.js.map +1 -0
  76. package/dist/server/routes/index.d.ts +10 -0
  77. package/dist/server/routes/index.d.ts.map +1 -0
  78. package/dist/server/routes/index.js +12 -0
  79. package/dist/server/routes/index.js.map +1 -0
  80. package/dist/server/routes/instances.d.ts +10 -0
  81. package/dist/server/routes/instances.d.ts.map +1 -0
  82. package/dist/server/routes/instances.js +47 -0
  83. package/dist/server/routes/instances.js.map +1 -0
  84. package/dist/server/routes/notifications.d.ts +3 -0
  85. package/dist/server/routes/notifications.d.ts.map +1 -0
  86. package/dist/server/routes/notifications.js +136 -0
  87. package/dist/server/routes/notifications.js.map +1 -0
  88. package/dist/server/services/analytics.d.ts +56 -0
  89. package/dist/server/services/analytics.d.ts.map +1 -0
  90. package/dist/server/services/analytics.js +240 -0
  91. package/dist/server/services/analytics.js.map +1 -0
  92. package/dist/server/services/auth.d.ts +26 -0
  93. package/dist/server/services/auth.d.ts.map +1 -0
  94. package/dist/server/services/auth.js +71 -0
  95. package/dist/server/services/auth.js.map +1 -0
  96. package/dist/server/services/client-id.d.ts +10 -0
  97. package/dist/server/services/client-id.d.ts.map +1 -0
  98. package/dist/server/services/client-id.js +61 -0
  99. package/dist/server/services/client-id.js.map +1 -0
  100. package/dist/server/services/credentials.d.ts +39 -0
  101. package/dist/server/services/credentials.d.ts.map +1 -0
  102. package/dist/server/services/credentials.js +110 -0
  103. package/dist/server/services/credentials.js.map +1 -0
  104. package/dist/server/services/files.d.ts +119 -0
  105. package/dist/server/services/files.d.ts.map +1 -0
  106. package/dist/server/services/files.js +560 -0
  107. package/dist/server/services/files.js.map +1 -0
  108. package/dist/server/services/instances.d.ts +52 -0
  109. package/dist/server/services/instances.d.ts.map +1 -0
  110. package/dist/server/services/instances.js +241 -0
  111. package/dist/server/services/instances.js.map +1 -0
  112. package/dist/server/services/pathUtils.d.ts +47 -0
  113. package/dist/server/services/pathUtils.d.ts.map +1 -0
  114. package/dist/server/services/pathUtils.js +124 -0
  115. package/dist/server/services/pathUtils.js.map +1 -0
  116. package/dist/server/services/platform.d.ts +72 -0
  117. package/dist/server/services/platform.d.ts.map +1 -0
  118. package/dist/server/services/platform.js +368 -0
  119. package/dist/server/services/platform.js.map +1 -0
  120. package/dist/server/services/sentry.d.ts +5 -0
  121. package/dist/server/services/sentry.d.ts.map +1 -0
  122. package/dist/server/services/sentry.js +71 -0
  123. package/dist/server/services/sentry.js.map +1 -0
  124. package/dist/server/services/terminal/pty-manager.d.ts +149 -0
  125. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -0
  126. package/dist/server/services/terminal/pty-manager.js +377 -0
  127. package/dist/server/services/terminal/pty-manager.js.map +1 -0
  128. package/dist/server/services/terminal/tmux-manager.d.ts +82 -0
  129. package/dist/server/services/terminal/tmux-manager.d.ts.map +1 -0
  130. package/dist/server/services/terminal/tmux-manager.js +352 -0
  131. package/dist/server/services/terminal/tmux-manager.js.map +1 -0
  132. package/dist/server/services/websocket/autocomplete.d.ts +50 -0
  133. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -0
  134. package/dist/server/services/websocket/autocomplete.js +361 -0
  135. package/dist/server/services/websocket/autocomplete.js.map +1 -0
  136. package/dist/server/services/websocket/file-utils.d.ts +44 -0
  137. package/dist/server/services/websocket/file-utils.d.ts.map +1 -0
  138. package/dist/server/services/websocket/file-utils.js +272 -0
  139. package/dist/server/services/websocket/file-utils.js.map +1 -0
  140. package/dist/server/services/websocket/handler.d.ts +246 -0
  141. package/dist/server/services/websocket/handler.d.ts.map +1 -0
  142. package/dist/server/services/websocket/handler.js +1771 -0
  143. package/dist/server/services/websocket/handler.js.map +1 -0
  144. package/dist/server/services/websocket/index.d.ts +11 -0
  145. package/dist/server/services/websocket/index.d.ts.map +1 -0
  146. package/dist/server/services/websocket/index.js +14 -0
  147. package/dist/server/services/websocket/index.js.map +1 -0
  148. package/dist/server/services/websocket/types.d.ts +214 -0
  149. package/dist/server/services/websocket/types.d.ts.map +1 -0
  150. package/dist/server/services/websocket/types.js +4 -0
  151. package/dist/server/services/websocket/types.js.map +1 -0
  152. package/dist/server/utils/agent-manager.d.ts +69 -0
  153. package/dist/server/utils/agent-manager.d.ts.map +1 -0
  154. package/dist/server/utils/agent-manager.js +269 -0
  155. package/dist/server/utils/agent-manager.js.map +1 -0
  156. package/dist/server/utils/paths.d.ts +25 -0
  157. package/dist/server/utils/paths.d.ts.map +1 -0
  158. package/dist/server/utils/paths.js +38 -0
  159. package/dist/server/utils/paths.js.map +1 -0
  160. package/dist/server/utils/port-manager.d.ts +10 -0
  161. package/dist/server/utils/port-manager.d.ts.map +1 -0
  162. package/dist/server/utils/port-manager.js +60 -0
  163. package/dist/server/utils/port-manager.js.map +1 -0
  164. package/dist/server/utils/port.d.ts +26 -0
  165. package/dist/server/utils/port.d.ts.map +1 -0
  166. package/dist/server/utils/port.js +83 -0
  167. package/dist/server/utils/port.js.map +1 -0
  168. package/hooks/bouncer.sh +138 -0
  169. package/package.json +74 -0
  170. package/server/README.md +191 -0
  171. package/server/cli/headless/claude-invoker.ts +415 -0
  172. package/server/cli/headless/index.ts +39 -0
  173. package/server/cli/headless/mcp-config.ts +87 -0
  174. package/server/cli/headless/output-utils.ts +109 -0
  175. package/server/cli/headless/prompt-utils.ts +108 -0
  176. package/server/cli/headless/runner.ts +133 -0
  177. package/server/cli/headless/types.ts +118 -0
  178. package/server/cli/improvisation-session-manager.ts +531 -0
  179. package/server/index.ts +456 -0
  180. package/server/mcp/README.md +122 -0
  181. package/server/mcp/bouncer-cli.ts +127 -0
  182. package/server/mcp/bouncer-integration.ts +430 -0
  183. package/server/mcp/security-audit.ts +180 -0
  184. package/server/mcp/security-patterns.ts +290 -0
  185. package/server/mcp/server.ts +174 -0
  186. package/server/routes/files.ts +29 -0
  187. package/server/routes/improvise.ts +82 -0
  188. package/server/routes/index.ts +13 -0
  189. package/server/routes/instances.ts +54 -0
  190. package/server/routes/notifications.ts +158 -0
  191. package/server/services/analytics.ts +277 -0
  192. package/server/services/auth.ts +80 -0
  193. package/server/services/client-id.ts +68 -0
  194. package/server/services/credentials.ts +134 -0
  195. package/server/services/files.ts +710 -0
  196. package/server/services/instances.ts +275 -0
  197. package/server/services/pathUtils.ts +158 -0
  198. package/server/services/platform.test.ts +1314 -0
  199. package/server/services/platform.ts +435 -0
  200. package/server/services/sentry.ts +81 -0
  201. package/server/services/terminal/pty-manager.ts +464 -0
  202. package/server/services/terminal/tmux-manager.ts +426 -0
  203. package/server/services/websocket/autocomplete.ts +438 -0
  204. package/server/services/websocket/file-utils.ts +305 -0
  205. package/server/services/websocket/handler.test.ts +20 -0
  206. package/server/services/websocket/handler.ts +2047 -0
  207. package/server/services/websocket/index.ts +40 -0
  208. package/server/services/websocket/types.ts +339 -0
  209. package/server/tsconfig.json +19 -0
  210. package/server/utils/agent-manager.ts +323 -0
  211. package/server/utils/paths.ts +45 -0
  212. package/server/utils/port-manager.ts +70 -0
  213. package/server/utils/port.ts +102 -0
@@ -0,0 +1,456 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Mstro Server (Node.js + Hono)
6
+ */
7
+
8
+ import { randomBytes } from 'node:crypto'
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import type { IncomingMessage } from 'node:http'
11
+ import { basename, join } from 'node:path'
12
+ import { serve } from '@hono/node-server'
13
+ import { Hono } from 'hono'
14
+ import { cors } from 'hono/cors'
15
+ import { logger } from 'hono/logger'
16
+ import { type WebSocket as NodeWebSocket, WebSocketServer } from 'ws'
17
+ // Import route creators
18
+ import {
19
+ createFileRoutes,
20
+ createImproviseRoutes,
21
+ createInstanceRoutes,
22
+ createNotificationRoutes,
23
+ createShutdownRoute
24
+ } from './routes/index.js'
25
+ import { AnalyticsEvents, initAnalytics, shutdownAnalytics, trackEvent } from './services/analytics.js'
26
+ import { AuthService } from './services/auth.js'
27
+ import { FileService } from './services/files.js'
28
+ import { InstanceRegistry } from './services/instances.js'
29
+ import { PlatformConnection } from './services/platform.js'
30
+ import { captureException, flushSentry, initSentry } from './services/sentry.js'
31
+ import { getPTYManager } from './services/terminal/pty-manager.js'
32
+ import { WebSocketImproviseHandler } from './services/websocket/index.js'
33
+ import type { WSContext } from './services/websocket/types.js'
34
+ import { findAvailablePort } from './utils/port.js'
35
+
36
+ /**
37
+ * Set the terminal tab title
38
+ * Format: "mstro: directory_name"
39
+ * Uses ANSI escape sequence: ESC ] 0 ; title BEL
40
+ */
41
+ function setTerminalTitle(directory: string): void {
42
+ const dirName = basename(directory) || directory
43
+ const title = `mstro: ${dirName}`
44
+ // ESC ] 0 ; title BEL - sets both window title and tab title
45
+ process.stdout.write(`\x1b]0;${title}\x07`)
46
+ }
47
+
48
+ // Create Hono app with type inference
49
+ const app = new Hono()
50
+
51
+ // Configuration
52
+ const DEFAULT_PORT = 4101
53
+ const REQUESTED_PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT
54
+ const WORKING_DIR = process.env.MSTRO_WORKING_DIR || process.env.WORKING_DIR || process.cwd()
55
+ const IS_PRODUCTION = process.env.NODE_ENV === 'production'
56
+
57
+ /**
58
+ * Ensure .claude/settings.json exists with recommended settings
59
+ * for optimal Claude Code performance with Mstro
60
+ */
61
+ function ensureClaudeSettings(workingDir: string): void {
62
+ const claudeDir = join(workingDir, '.claude')
63
+ const settingsPath = join(claudeDir, 'settings.json')
64
+
65
+ // Create .claude directory if it doesn't exist
66
+ if (!existsSync(claudeDir)) {
67
+ mkdirSync(claudeDir, { recursive: true })
68
+ }
69
+
70
+ // Recommended settings for Mstro
71
+ const recommendedSettings = {
72
+ env: {
73
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: "64000",
74
+ DISABLE_NONESSENTIAL_TRAFFIC: "1"
75
+ }
76
+ }
77
+
78
+ // If settings.json doesn't exist, create it
79
+ if (!existsSync(settingsPath)) {
80
+ writeFileSync(settingsPath, JSON.stringify(recommendedSettings, null, 2))
81
+ console.log(`📝 Created .claude/settings.json with recommended settings`)
82
+ } else {
83
+ // If it exists, check if our env settings are present and merge if needed
84
+ try {
85
+ const existingSettings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
86
+ let updated = false
87
+
88
+ // Ensure env object exists
89
+ if (!existingSettings.env) {
90
+ existingSettings.env = {}
91
+ updated = true
92
+ }
93
+
94
+ // Add our recommended env settings if they don't exist
95
+ if (!existingSettings.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
96
+ existingSettings.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = "64000"
97
+ updated = true
98
+ }
99
+ if (!existingSettings.env.DISABLE_NONESSENTIAL_TRAFFIC) {
100
+ existingSettings.env.DISABLE_NONESSENTIAL_TRAFFIC = "1"
101
+ updated = true
102
+ }
103
+
104
+ if (updated) {
105
+ writeFileSync(settingsPath, JSON.stringify(existingSettings, null, 2))
106
+ console.log(`📝 Updated .claude/settings.json with recommended env settings`)
107
+ }
108
+ } catch (_e) {
109
+ // If we can't parse the existing file, don't overwrite it
110
+ console.warn(`⚠️ Could not parse existing .claude/settings.json, skipping update`)
111
+ }
112
+ }
113
+ }
114
+
115
+ // Ensure Claude settings on startup
116
+ ensureClaudeSettings(WORKING_DIR)
117
+
118
+ // Set terminal tab title to show mstro is running and which directory
119
+ setTerminalTitle(WORKING_DIR)
120
+
121
+ // Initialize services
122
+ const authService = new AuthService()
123
+ const instanceRegistry = new InstanceRegistry()
124
+ const fileService = new FileService(WORKING_DIR)
125
+ const wsHandler = new WebSocketImproviseHandler()
126
+
127
+ // Instance registration deferred to startServer() when port is known
128
+ let _currentInstance: any
129
+
130
+ // Global middleware
131
+ // In production, restrict CORS to block cross-origin browser requests to localhost.
132
+ // In dev, allow localhost origins on any port for local frontend dev servers.
133
+ app.use('*', cors({
134
+ origin: (origin) => {
135
+ if (!origin) return 'http://localhost'
136
+ try {
137
+ const url = new URL(origin)
138
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
139
+ return origin
140
+ }
141
+ } catch {}
142
+ return 'http://localhost'
143
+ }
144
+ }))
145
+ app.use('*', logger())
146
+
147
+ // ========================================
148
+ // Authentication Middleware
149
+ // ========================================
150
+
151
+ const authMiddleware = async (c: any, next: any) => {
152
+ // Skip auth for health check and config
153
+ const publicPaths = ['/health', '/api/config']
154
+ if (publicPaths.some(path => c.req.path.startsWith(path))) {
155
+ return next()
156
+ }
157
+
158
+ // Require the local session token for localhost security.
159
+ // This prevents other local processes or malicious websites from
160
+ // calling the API without the session token from ~/.mstro/session-token.
161
+ const token = c.req.header('x-session-token')
162
+ if (!token || !authService.validateLocalToken(token)) {
163
+ return c.json({ error: 'Unauthorized' }, 401)
164
+ }
165
+
166
+ return next()
167
+ }
168
+
169
+ app.use('/api/*', authMiddleware)
170
+
171
+ // ========================================
172
+ // Health & Configuration
173
+ // ========================================
174
+
175
+ // Read version from package.json once at startup
176
+ const PKG_VERSION = (() => {
177
+ try {
178
+ const pkg = JSON.parse(readFileSync(join(import.meta.dirname || '.', '..', 'package.json'), 'utf-8'))
179
+ return pkg.version || '0.0.0'
180
+ } catch {
181
+ return '0.0.0'
182
+ }
183
+ })()
184
+
185
+ app.get('/health', (c) => {
186
+ return c.json({
187
+ status: 'ok',
188
+ timestamp: new Date().toISOString(),
189
+ version: PKG_VERSION
190
+ })
191
+ })
192
+
193
+ app.get('/api/config', (c) => {
194
+ return c.json({
195
+ version: PKG_VERSION
196
+ })
197
+ })
198
+
199
+ // ========================================
200
+ // Mount Routes
201
+ // ========================================
202
+
203
+ app.route('/api/instances', createInstanceRoutes(instanceRegistry))
204
+ app.route('/api/shutdown', createShutdownRoute(instanceRegistry))
205
+ app.route('/api/improvise', createImproviseRoutes(WORKING_DIR))
206
+ app.route('/api/files', createFileRoutes(fileService))
207
+ app.route('/api/notifications', createNotificationRoutes(WORKING_DIR))
208
+
209
+ // ========================================
210
+ // Static File Serving (Production Only)
211
+ // ========================================
212
+
213
+ if (IS_PRODUCTION) {
214
+ // For production static file serving, use a reverse proxy like nginx
215
+ // or implement a simple static file middleware if needed
216
+ console.log('Production mode: serve static files via nginx or similar')
217
+ }
218
+
219
+ // ========================================
220
+ // 404 & Error Handlers
221
+ // ========================================
222
+
223
+ app.notFound((c) => {
224
+ return c.json({ error: 'Not found' }, 404)
225
+ })
226
+
227
+ app.onError((err, c) => {
228
+ const errorId = randomBytes(4).toString('hex')
229
+ console.error(`Server error [${errorId}]:`, err)
230
+ captureException(err, { errorId, path: c.req.path, method: c.req.method })
231
+ return c.json({
232
+ error: 'Internal server error',
233
+ errorId,
234
+ message: 'Something went wrong. If this persists, report this error ID to support.'
235
+ }, 500)
236
+ })
237
+
238
+ // ========================================
239
+ // Node.js Server with WebSocket Support
240
+ // ========================================
241
+
242
+ /**
243
+ * Wrap a ws WebSocket to match our WSContext interface
244
+ */
245
+ function wrapWebSocket(ws: NodeWebSocket, workingDir: string): WSContext {
246
+ return {
247
+ send: (data: string | Buffer) => ws.send(data),
248
+ close: () => ws.close(),
249
+ readyState: ws.readyState,
250
+ _workingDir: workingDir,
251
+ _ws: ws
252
+ } as WSContext
253
+ }
254
+
255
+ /**
256
+ * Create a virtual WebSocket context that sends responses through the platform relay
257
+ * This allows messages from the web (via platform) to be handled by the same wsHandler
258
+ */
259
+ function createPlatformRelayContext(
260
+ platformSend: (message: any) => void,
261
+ workingDir: string
262
+ ): WSContext {
263
+ return {
264
+ send: (data: string | Buffer) => {
265
+ // Parse the response and send through platform relay
266
+ try {
267
+ const response = typeof data === 'string' ? JSON.parse(data) : JSON.parse(data.toString())
268
+ platformSend(response)
269
+ } catch (e) {
270
+ // If not JSON, send as-is (shouldn't happen with our protocol)
271
+ console.error('[PlatformRelay] Failed to parse response:', e)
272
+ }
273
+ },
274
+ close: () => {
275
+ // No-op for platform relay - connection is managed by PlatformConnection
276
+ },
277
+ readyState: 1, // WebSocket.OPEN
278
+ _workingDir: workingDir,
279
+ _isPlatformRelay: true
280
+ } as WSContext
281
+ }
282
+
283
+ // Start server with dynamic port selection
284
+ async function startServer() {
285
+ // Initialize error tracking (must be first)
286
+ initSentry()
287
+
288
+ // Initialize analytics (fetches config from platform)
289
+ await initAnalytics()
290
+
291
+ const PORT = await findAvailablePort(REQUESTED_PORT, 20)
292
+
293
+ if (PORT !== REQUESTED_PORT) {
294
+ console.log(`⚠️ Port ${REQUESTED_PORT} in use, using port ${PORT}`)
295
+ }
296
+
297
+ _currentInstance = instanceRegistry.register(PORT, WORKING_DIR)
298
+
299
+ // Create HTTP server with Hono
300
+ const server = serve({
301
+ fetch: app.fetch,
302
+ port: PORT
303
+ })
304
+
305
+ // Create WebSocket server attached to the HTTP server
306
+ const wss = new WebSocketServer({ server: server as any })
307
+
308
+ wss.on('connection', (ws: NodeWebSocket, req: IncomingMessage) => {
309
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`)
310
+
311
+ // Only handle /ws endpoint
312
+ if (url.pathname !== '/ws') {
313
+ ws.close(1008, 'Invalid WebSocket path')
314
+ return
315
+ }
316
+
317
+ // Require local session token for WebSocket connections
318
+ const wsToken = url.searchParams.get('token')
319
+ if (!wsToken || !authService.validateLocalToken(wsToken)) {
320
+ ws.close(4001, 'Unauthorized')
321
+ return
322
+ }
323
+
324
+ // Always use the server's working directory — don't allow clients to override
325
+ const workingDir = WORKING_DIR
326
+ const wrappedWs = wrapWebSocket(ws, workingDir)
327
+
328
+ wsHandler.handleConnection(wrappedWs, workingDir)
329
+
330
+ ws.on('message', (data: Buffer | string) => {
331
+ const message = typeof data === 'string' ? data : data.toString('utf-8')
332
+ wsHandler.handleMessage(wrappedWs, message, workingDir)
333
+ })
334
+
335
+ ws.on('close', () => {
336
+ wsHandler.handleClose(wrappedWs)
337
+ })
338
+
339
+ ws.on('error', (error: Error) => {
340
+ console.error('[WebSocket] Error:', error)
341
+ captureException(error, { context: 'websocket.connection' })
342
+ })
343
+ })
344
+
345
+ console.log(`🚀 Mstro Server (Node.js + Hono) on port ${PORT}`)
346
+ console.log(`📁 Working directory: ${WORKING_DIR}`)
347
+ console.log(`Runtime: Node.js ${process.version}`)
348
+ console.log(`Framework: Hono`)
349
+
350
+ // Track server started event
351
+ trackEvent(AnalyticsEvents.SERVER_STARTED, { port: PORT })
352
+
353
+ // Create a virtual WebSocket context for platform relay
354
+ // This allows messages from the web (via platform) to use the same wsHandler
355
+ let platformRelayContext: WSContext | null = null
356
+
357
+ // Queue for messages that arrive before relay context is ready
358
+ // This handles race conditions where initTab arrives before web_connected
359
+ let pendingRelayMessages: any[] = []
360
+
361
+ // Connect to platform
362
+ const platformConnection = new PlatformConnection(WORKING_DIR, {
363
+ onConnected: (_connectionId) => {
364
+ console.log(`🎵 Orchestra ready: ${basename(WORKING_DIR)}`)
365
+
366
+ // Set up usage reporter to send token usage to platform
367
+ wsHandler.setUsageReporter((report) => {
368
+ platformConnection.send({
369
+ type: 'reportUsage',
370
+ data: report
371
+ })
372
+ })
373
+ },
374
+ onWebConnected: () => {
375
+ // Create the relay context when web connects
376
+ platformRelayContext = createPlatformRelayContext(
377
+ (message) => platformConnection.send(message),
378
+ WORKING_DIR
379
+ )
380
+ // Initialize the connection for the wsHandler
381
+ wsHandler.handleConnection(platformRelayContext, WORKING_DIR)
382
+
383
+ // Process any messages that arrived before relay context was ready
384
+ if (pendingRelayMessages.length > 0) {
385
+ for (const message of pendingRelayMessages) {
386
+ wsHandler.handleMessage(platformRelayContext, JSON.stringify(message), WORKING_DIR)
387
+ }
388
+ pendingRelayMessages = []
389
+ }
390
+ },
391
+ onWebDisconnected: () => {
392
+ // Clean up when web disconnects
393
+ if (platformRelayContext) {
394
+ wsHandler.handleClose(platformRelayContext)
395
+ platformRelayContext = null
396
+ }
397
+ // Clear any pending messages
398
+ pendingRelayMessages = []
399
+ },
400
+ onRelayedMessage: (message) => {
401
+ // Forward messages from web (via platform) to the wsHandler
402
+ if (platformRelayContext) {
403
+ wsHandler.handleMessage(platformRelayContext, JSON.stringify(message), WORKING_DIR)
404
+ } else {
405
+ // Queue the message - it will be processed when web_connected arrives
406
+ pendingRelayMessages.push(message)
407
+ }
408
+ }
409
+ })
410
+ platformConnection.connect()
411
+
412
+ // Catch unhandled errors at process level
413
+ process.on('uncaughtException', (err) => {
414
+ console.error('[Server] Uncaught exception:', err)
415
+ captureException(err, { context: 'uncaughtException' })
416
+ })
417
+
418
+ process.on('unhandledRejection', (reason) => {
419
+ console.error('[Server] Unhandled rejection:', reason)
420
+ captureException(reason instanceof Error ? reason : new Error(String(reason)), { context: 'unhandledRejection' })
421
+ })
422
+
423
+ // Cleanup on exit
424
+ process.on('SIGINT', async () => {
425
+ trackEvent(AnalyticsEvents.SERVER_STOPPED)
426
+ await Promise.all([shutdownAnalytics(), flushSentry()])
427
+ platformConnection.disconnect()
428
+ instanceRegistry.unregister()
429
+ // Close all non-persistent terminal sessions (PTY processes)
430
+ // Note: Persistent (tmux) sessions are intentionally left running
431
+ getPTYManager().closeAll()
432
+ wss.close()
433
+ console.log('\n\n👋 Shutting down gracefully...\n')
434
+ process.exit(0)
435
+ })
436
+
437
+ process.on('SIGTERM', async () => {
438
+ trackEvent(AnalyticsEvents.SERVER_STOPPED)
439
+ await Promise.all([shutdownAnalytics(), flushSentry()])
440
+ platformConnection.disconnect()
441
+ instanceRegistry.unregister()
442
+ // Close all non-persistent terminal sessions (PTY processes)
443
+ // Note: Persistent (tmux) sessions are intentionally left running
444
+ getPTYManager().closeAll()
445
+ wss.close()
446
+ console.log('\n\n👋 Shutting down gracefully...\n')
447
+ process.exit(0)
448
+ })
449
+
450
+ // Periodic cleanup
451
+ setInterval(() => {
452
+ wsHandler.cleanupStaleSessions()
453
+ }, 5 * 60 * 1000) // Every 5 minutes
454
+ }
455
+
456
+ startServer()
@@ -0,0 +1,122 @@
1
+ # Mstro MCP Bouncer Server
2
+
3
+ This directory contains the Model Context Protocol (MCP) server implementation for Mstro v2's security bouncer.
4
+
5
+ ## Overview
6
+
7
+ The MCP bouncer server provides permission approval/denial for Claude Code tool use via the MCP protocol. It integrates with Mstro's security analysis system to review potentially risky operations before they execute.
8
+
9
+ ## Architecture
10
+
11
+ The bouncer uses a 2-layer security system:
12
+
13
+ ### Layer 1: Pattern-Based Fast Path (~95% of operations, <5ms)
14
+ - **Critical threats** → Immediate DENY (99% confidence)
15
+ - **Known-safe operations** → Immediate ALLOW (95% confidence)
16
+ - Uses consolidated security patterns
17
+
18
+ ### Layer 2: Haiku AI Analysis (~5% of operations, 200-500ms)
19
+ - Lightweight AI for ambiguous cases
20
+ - Context-aware decisions with reasoning
21
+ - Uses Claude Code headless pattern (spawn + stdin)
22
+ - Variable confidence (50-90%)
23
+
24
+ ## Files
25
+
26
+ - **server.ts** - Main MCP server entry point
27
+ - **bouncer-integration.ts** - Core security review logic
28
+ - **security-patterns.ts** - Pattern definitions for fast-path security checks
29
+ - **security-audit.ts** - Audit logging system
30
+
31
+ ## Usage
32
+
33
+ ### Starting the Server
34
+
35
+ ```bash
36
+ # From mstro-v2 root directory
37
+ npm run dev:mcp
38
+
39
+ # Or directly with bun
40
+ bun run server/mcp/server.ts
41
+ ```
42
+
43
+ ### Configuration
44
+
45
+ The server is configured via `mstro-bouncer-mcp.json` in the project root:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "mstro-bouncer": {
51
+ "command": "bun",
52
+ "args": ["run", "server/mcp/server.ts"],
53
+ "description": "Mstro security bouncer for approving/denying Claude Code tool use",
54
+ "env": {
55
+ "BOUNCER_USE_AI": "true"
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### Using with Claude Code
63
+
64
+ ```bash
65
+ claude --print --permission-prompt-tool mcp__mstro-bouncer__approval_prompt \
66
+ --mcp-config mstro-bouncer-mcp.json \
67
+ "your prompt here"
68
+ ```
69
+
70
+ ## Environment Variables
71
+
72
+ - **BOUNCER_USE_AI** - Enable/disable AI analysis (default: `true`)
73
+ - Set to `false` to use only pattern-based checks
74
+ - **CLAUDE_COMMAND** - Claude CLI command (default: `claude`)
75
+
76
+ ## Security Patterns
77
+
78
+ ### Critical Threats (Auto-deny)
79
+ - Root/home directory deletion (`rm -rf / or ~`)
80
+ - Fork bombs
81
+ - Disk device overwrites
82
+ - Filesystem formatting
83
+ - Obfuscated code execution
84
+
85
+ ### Safe Operations (Auto-allow)
86
+ - Read/Glob/Grep operations
87
+ - Common package manager commands (`npm install`, `yarn build`)
88
+ - Git operations (`status`, `log`, `diff`)
89
+ - Safe file deletions (`node_modules`, `dist`, `build`)
90
+
91
+ ### Requires AI Review
92
+ - Pipe-to-shell from remote sources
93
+ - Sudo operations
94
+ - Writing executable files
95
+ - System directory modifications
96
+ - Custom script execution
97
+
98
+ ## Audit Logging
99
+
100
+ All security decisions are logged to `./logs/security/bouncer-audit.jsonl` in JSON Lines format.
101
+
102
+ Example log entry:
103
+ ```json
104
+ {
105
+ "timestamp": "2025-11-15T12:00:00.000Z",
106
+ "operation": "Bash: rm -rf node_modules",
107
+ "decision": "allow",
108
+ "confidence": 95,
109
+ "reasoning": "Operation matches known-safe patterns",
110
+ "threatLevel": "low"
111
+ }
112
+ ```
113
+
114
+ ## Performance
115
+
116
+ - 95%+ operations resolve in <5ms (Layer 1)
117
+ - 5% require AI analysis (~200-500ms)
118
+ - No ANTHROPIC_API_KEY required - uses existing Claude installation
119
+
120
+ ## Integration
121
+
122
+ The MCP server runs separately from the web application servers and is only needed when using Claude Code's permission prompts feature. It does NOT auto-start with `npm start` or the web servers.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
3
+ // Licensed under the MIT License. See LICENSE file for details.
4
+
5
+ /**
6
+ * Bouncer CLI - Shell-callable wrapper for Mstro security bouncer
7
+ *
8
+ * This CLI reads Claude Code hook input from stdin and returns a security decision.
9
+ * It's designed to be called from bouncer.sh.
10
+ *
11
+ * Input (stdin): Claude Code PreToolUse hook JSON payload
12
+ * Output (stdout): JSON decision { decision: "allow"|"deny", reason: string }
13
+ *
14
+ * The hook payload includes conversation context that we pass to the bouncer
15
+ * so it can make context-aware decisions.
16
+ */
17
+
18
+ import { type BouncerReviewRequest, reviewOperation } from './bouncer-integration.js';
19
+
20
+ interface HookInput {
21
+ tool_name?: string;
22
+ toolName?: string;
23
+ input?: Record<string, any>;
24
+ toolInput?: Record<string, any>;
25
+ // Conversation context from Claude Code hooks
26
+ session_id?: string;
27
+ conversation?: {
28
+ messages?: Array<{
29
+ role: 'user' | 'assistant';
30
+ content: string;
31
+ }>;
32
+ last_user_message?: string;
33
+ };
34
+ // Additional context fields Claude Code may provide
35
+ tool_use_id?: string;
36
+ working_directory?: string;
37
+ }
38
+
39
+ /**
40
+ * Read all data from stdin (Node.js compatible)
41
+ */
42
+ async function readStdin(): Promise<string> {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks: Buffer[] = [];
45
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
46
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()));
47
+ process.stdin.on('error', reject);
48
+ });
49
+ }
50
+
51
+ function buildOperationString(toolName: string, toolInput: Record<string, any>): string {
52
+ if (toolName === 'Bash' && toolInput.command) {
53
+ return `${toolName}: ${toolInput.command}`;
54
+ }
55
+ if (['Write', 'Edit', 'Read'].includes(toolName)) {
56
+ const filePath = toolInput.file_path || toolInput.filePath || toolInput.path;
57
+ return filePath ? `${toolName}: ${filePath}` : `${toolName}: ${JSON.stringify(toolInput)}`;
58
+ }
59
+ return `${toolName}: ${JSON.stringify(toolInput)}`;
60
+ }
61
+
62
+ function extractConversationContext(hookInput: HookInput): string | undefined {
63
+ const lastUserMessage = hookInput.conversation?.last_user_message;
64
+ if (lastUserMessage) return `User's request: "${lastUserMessage}"`;
65
+
66
+ const recentMessages = hookInput.conversation?.messages?.slice(-5);
67
+ if (recentMessages?.length) {
68
+ return `Recent conversation:\n${recentMessages.map(m => `${m.role}: ${m.content}`).join('\n')}`;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ async function main() {
74
+ const inputStr = await readStdin();
75
+
76
+ if (!inputStr) {
77
+ console.log(JSON.stringify({ decision: 'allow', reason: 'Empty input, allowing' }));
78
+ process.exit(0);
79
+ }
80
+
81
+ let hookInput: HookInput;
82
+ try {
83
+ hookInput = JSON.parse(inputStr);
84
+ } catch (e) {
85
+ console.error('[bouncer-cli] Failed to parse input JSON:', e);
86
+ console.log(JSON.stringify({ decision: 'allow', reason: 'Invalid JSON input, allowing' }));
87
+ process.exit(0);
88
+ }
89
+
90
+ const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
91
+ const toolInput = hookInput.input || hookInput.toolInput || {};
92
+ const userRequestContext = extractConversationContext(hookInput);
93
+ const lastUserMessage = hookInput.conversation?.last_user_message;
94
+ const recentMessages = hookInput.conversation?.messages?.slice(-5);
95
+
96
+ const bouncerRequest: BouncerReviewRequest = {
97
+ operation: buildOperationString(toolName, toolInput),
98
+ context: {
99
+ purpose: userRequestContext || 'Tool use request from Claude',
100
+ workingDirectory: hookInput.working_directory || process.cwd(),
101
+ toolName,
102
+ toolInput,
103
+ userRequest: lastUserMessage,
104
+ conversationHistory: recentMessages?.map(m => `${m.role}: ${m.content}`),
105
+ sessionId: hookInput.session_id,
106
+ },
107
+ };
108
+
109
+ try {
110
+ const decision = await reviewOperation(bouncerRequest);
111
+ console.log(JSON.stringify({
112
+ decision: decision.decision === 'deny' ? 'deny' : 'allow',
113
+ reason: decision.reasoning,
114
+ confidence: decision.confidence,
115
+ threatLevel: decision.threatLevel,
116
+ alternative: decision.alternative,
117
+ }));
118
+ } catch (error: any) {
119
+ console.error('[bouncer-cli] Error:', error.message);
120
+ console.log(JSON.stringify({
121
+ decision: 'allow',
122
+ reason: `Bouncer error: ${error.message}. Allowing to avoid blocking.`
123
+ }));
124
+ }
125
+ }
126
+
127
+ main();