mstro-app 0.1.57 → 0.2.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 (100) hide show
  1. package/bin/commands/login.js +27 -14
  2. package/bin/commands/logout.js +35 -1
  3. package/bin/commands/status.js +1 -1
  4. package/bin/mstro.js +5 -108
  5. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +432 -103
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/index.d.ts +2 -1
  9. package/dist/server/cli/headless/index.d.ts.map +1 -1
  10. package/dist/server/cli/headless/index.js +2 -0
  11. package/dist/server/cli/headless/index.js.map +1 -1
  12. package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
  13. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
  14. package/dist/server/cli/headless/prompt-utils.js +40 -5
  15. package/dist/server/cli/headless/prompt-utils.js.map +1 -1
  16. package/dist/server/cli/headless/runner.d.ts +1 -1
  17. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  18. package/dist/server/cli/headless/runner.js +29 -7
  19. package/dist/server/cli/headless/runner.js.map +1 -1
  20. package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +336 -20
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
  26. package/dist/server/cli/headless/tool-watchdog.js +296 -0
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
  28. package/dist/server/cli/headless/types.d.ts +80 -1
  29. package/dist/server/cli/headless/types.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +737 -132
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/index.js +5 -10
  35. package/dist/server/index.js.map +1 -1
  36. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  37. package/dist/server/mcp/bouncer-integration.js +18 -0
  38. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  39. package/dist/server/mcp/security-audit.d.ts +2 -2
  40. package/dist/server/mcp/security-audit.d.ts.map +1 -1
  41. package/dist/server/mcp/security-audit.js +12 -8
  42. package/dist/server/mcp/security-audit.js.map +1 -1
  43. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  44. package/dist/server/mcp/security-patterns.js +9 -4
  45. package/dist/server/mcp/security-patterns.js.map +1 -1
  46. package/dist/server/routes/improvise.js +6 -6
  47. package/dist/server/routes/improvise.js.map +1 -1
  48. package/dist/server/services/analytics.d.ts +2 -0
  49. package/dist/server/services/analytics.d.ts.map +1 -1
  50. package/dist/server/services/analytics.js +13 -3
  51. package/dist/server/services/analytics.js.map +1 -1
  52. package/dist/server/services/platform.d.ts.map +1 -1
  53. package/dist/server/services/platform.js +4 -9
  54. package/dist/server/services/platform.js.map +1 -1
  55. package/dist/server/services/sandbox-utils.d.ts +6 -0
  56. package/dist/server/services/sandbox-utils.d.ts.map +1 -0
  57. package/dist/server/services/sandbox-utils.js +72 -0
  58. package/dist/server/services/sandbox-utils.js.map +1 -0
  59. package/dist/server/services/settings.d.ts +6 -0
  60. package/dist/server/services/settings.d.ts.map +1 -1
  61. package/dist/server/services/settings.js +21 -0
  62. package/dist/server/services/settings.js.map +1 -1
  63. package/dist/server/services/terminal/pty-manager.d.ts +3 -51
  64. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  65. package/dist/server/services/terminal/pty-manager.js +14 -100
  66. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  67. package/dist/server/services/websocket/handler.d.ts +36 -15
  68. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  69. package/dist/server/services/websocket/handler.js +452 -223
  70. package/dist/server/services/websocket/handler.js.map +1 -1
  71. package/dist/server/services/websocket/types.d.ts +6 -2
  72. package/dist/server/services/websocket/types.d.ts.map +1 -1
  73. package/hooks/bouncer.sh +11 -4
  74. package/package.json +4 -1
  75. package/server/cli/headless/claude-invoker.ts +602 -119
  76. package/server/cli/headless/index.ts +7 -1
  77. package/server/cli/headless/prompt-utils.ts +37 -5
  78. package/server/cli/headless/runner.ts +30 -8
  79. package/server/cli/headless/stall-assessor.ts +453 -22
  80. package/server/cli/headless/tool-watchdog.ts +390 -0
  81. package/server/cli/headless/types.ts +84 -1
  82. package/server/cli/improvisation-session-manager.ts +884 -143
  83. package/server/index.ts +5 -10
  84. package/server/mcp/bouncer-integration.ts +28 -0
  85. package/server/mcp/security-audit.ts +12 -8
  86. package/server/mcp/security-patterns.ts +8 -2
  87. package/server/routes/improvise.ts +6 -6
  88. package/server/services/analytics.ts +13 -3
  89. package/server/services/platform.test.ts +0 -10
  90. package/server/services/platform.ts +4 -10
  91. package/server/services/sandbox-utils.ts +78 -0
  92. package/server/services/settings.ts +25 -0
  93. package/server/services/terminal/pty-manager.ts +16 -127
  94. package/server/services/websocket/handler.ts +515 -251
  95. package/server/services/websocket/types.ts +10 -4
  96. package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
  97. package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
  98. package/dist/server/services/terminal/tmux-manager.js +0 -352
  99. package/dist/server/services/terminal/tmux-manager.js.map +0 -1
  100. package/server/services/terminal/tmux-manager.ts +0 -426
package/server/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import { randomBytes } from 'node:crypto'
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
10
  import type { IncomingMessage } from 'node:http'
11
+ import { homedir } from 'node:os'
11
12
  import { basename, join } from 'node:path'
12
13
  import { serve } from '@hono/node-server'
13
14
  import { Hono } from 'hono'
@@ -213,7 +214,6 @@ app.route('/api/notifications', createNotificationRoutes(WORKING_DIR))
213
214
  if (IS_PRODUCTION) {
214
215
  // For production static file serving, use a reverse proxy like nginx
215
216
  // or implement a simple static file middleware if needed
216
- console.log('Production mode: serve static files via nginx or similar')
217
217
  }
218
218
 
219
219
  // ========================================
@@ -342,10 +342,9 @@ async function startServer() {
342
342
  })
343
343
  })
344
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`)
345
+ const home = homedir()
346
+ const displayDir = WORKING_DIR.startsWith(home) ? `~${WORKING_DIR.slice(home.length)}` : WORKING_DIR
347
+ console.log(`Machine: ${displayDir}`)
349
348
 
350
349
  // Track server started event
351
350
  trackEvent(AnalyticsEvents.SERVER_STARTED, {
@@ -364,7 +363,7 @@ async function startServer() {
364
363
  // Connect to platform
365
364
  const platformConnection = new PlatformConnection(WORKING_DIR, {
366
365
  onConnected: (_connectionId) => {
367
- console.log(`🎵 Orchestra ready: ${basename(WORKING_DIR)}`)
366
+ console.log(`Connected: https://mstro.app`)
368
367
 
369
368
  // Set up usage reporter to send token usage to platform
370
369
  wsHandler.setUsageReporter((report) => {
@@ -429,8 +428,6 @@ async function startServer() {
429
428
  await Promise.all([shutdownAnalytics(), flushSentry()])
430
429
  platformConnection.disconnect()
431
430
  instanceRegistry.unregister()
432
- // Close all non-persistent terminal sessions (PTY processes)
433
- // Note: Persistent (tmux) sessions are intentionally left running
434
431
  getPTYManager().closeAll()
435
432
  wss.close()
436
433
  console.log('\n\n👋 Shutting down gracefully...\n')
@@ -442,8 +439,6 @@ async function startServer() {
442
439
  await Promise.all([shutdownAnalytics(), flushSentry()])
443
440
  platformConnection.disconnect()
444
441
  instanceRegistry.unregister()
445
- // Close all non-persistent terminal sessions (PTY processes)
446
- // Note: Persistent (tmux) sessions are intentionally left running
447
442
  getPTYManager().closeAll()
448
443
  wss.close()
449
444
  console.log('\n\n👋 Shutting down gracefully...\n')
@@ -251,6 +251,34 @@ export async function reviewOperation(request: BouncerReviewRequest): Promise<Bo
251
251
  console.error(`[Bouncer] User request: ${request.context.userRequest}`);
252
252
  }
253
253
 
254
+ // ========================================
255
+ // PRE-CHECK: Malformed/empty tool calls
256
+ // ========================================
257
+ // Empty-param Edit/Write calls are no-ops that will fail validation anyway.
258
+ // Allow immediately instead of wasting ~8s on Haiku analysis.
259
+ const toolInput = request.context?.toolInput;
260
+ if (toolInput && typeof toolInput === 'object' && Object.keys(toolInput).length === 0) {
261
+ console.error('[Bouncer] ⚡ Fast path: Empty tool parameters (no-op)');
262
+ const latencyMs = Math.round(performance.now() - startTime);
263
+
264
+ const decision: BouncerDecision = {
265
+ decision: 'allow',
266
+ confidence: 95,
267
+ reasoning: 'Empty tool parameters - operation is a no-op with no side effects.',
268
+ threatLevel: 'low'
269
+ };
270
+
271
+ logBouncerDecision(
272
+ operation,
273
+ decision.decision,
274
+ decision.confidence,
275
+ decision.reasoning,
276
+ { context: request.context, threatLevel: decision.threatLevel, layer: 'pattern-noop', latencyMs }
277
+ );
278
+
279
+ return decision;
280
+ }
281
+
254
282
  // ========================================
255
283
  // LAYER 1: Pattern-Based Fast Path (< 5ms)
256
284
  // ========================================
@@ -10,8 +10,8 @@
10
10
  import { appendFileSync, existsSync, mkdirSync, } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
 
13
- // Default log directory inside .mstro/ sibling directory
14
- const DEFAULT_LOG_DIR = './.mstro/logs/security';
13
+ // Default log subdirectory inside .mstro/
14
+ const DEFAULT_LOG_SUBDIR = '.mstro/logs';
15
15
 
16
16
  export type BouncerLayer = 'pattern-critical' | 'pattern-safe' | 'pattern-default' | 'haiku-ai' | 'ai-disabled' | 'ai-error';
17
17
 
@@ -33,7 +33,8 @@ export interface AuditLogEntry {
33
33
  export class SecurityAuditLogger {
34
34
  private logFile: string;
35
35
 
36
- constructor(logDir: string = DEFAULT_LOG_DIR) {
36
+ constructor(workingDir?: string) {
37
+ const logDir = join(workingDir || process.cwd(), DEFAULT_LOG_SUBDIR);
37
38
  this.logFile = join(logDir, 'bouncer-audit.jsonl');
38
39
 
39
40
  // Ensure log directory exists
@@ -88,12 +89,14 @@ export class SecurityAuditLogger {
88
89
 
89
90
  }
90
91
 
91
- // Singleton instance
92
+ // Singleton instance (keyed by workingDir to support multiple projects)
92
93
  let auditLogger: SecurityAuditLogger | null = null;
94
+ let auditLoggerWorkingDir: string | undefined;
93
95
 
94
- export function getAuditLogger(): SecurityAuditLogger {
95
- if (!auditLogger) {
96
- auditLogger = new SecurityAuditLogger();
96
+ export function getAuditLogger(workingDir?: string): SecurityAuditLogger {
97
+ if (!auditLogger || (workingDir && workingDir !== auditLoggerWorkingDir)) {
98
+ auditLogger = new SecurityAuditLogger(workingDir);
99
+ auditLoggerWorkingDir = workingDir;
97
100
  }
98
101
  return auditLogger;
99
102
  }
@@ -113,7 +116,8 @@ export function logBouncerDecision(
113
116
  const validDecisions = ['allow', 'deny', 'warn_allow'];
114
117
  const normalizedDecision = validDecisions.includes(safeDecision) ? safeDecision : 'deny';
115
118
 
116
- const logger = getAuditLogger();
119
+ const workingDir = metadata?.context?.workingDirectory;
120
+ const logger = getAuditLogger(workingDir);
117
121
  logger.logDecision(operation, normalizedDecision as 'allow' | 'deny' | 'warn_allow', confidence, reasoning, metadata);
118
122
 
119
123
  // Also log to console for real-time monitoring
@@ -125,6 +125,9 @@ export const SAFE_OPERATIONS: SecurityPattern[] = [
125
125
  // Write/Edit to temp directories - ephemeral, low risk
126
126
  { pattern: /^(Write|Edit):\s*\/tmp\//i },
127
127
  { pattern: /^(Write|Edit):\s*\/var\/tmp\//i },
128
+
129
+ // Side-effect-free tools - no dangerous operations possible
130
+ { pattern: /^(ExitPlanMode|EnterPlanMode|TodoWrite|AskUserQuestion):/i },
128
131
  ];
129
132
 
130
133
  /**
@@ -201,8 +204,11 @@ export function requiresAIReview(operation: string): boolean {
201
204
  return !SAFE_RM_PATTERNS.some(p => p.test(operation));
202
205
  }
203
206
 
204
- if (/\$\{.*\}|\$\(.*\)/.test(operation) || /\*\*?/.test(operation)) return true;
205
- if (/^Bash:\s*\.\//.test(operation)) return true;
207
+ // Variable expansion and glob patterns are only concerning in Bash commands
208
+ if (/^Bash:/.test(operation)) {
209
+ if (/\$\{.*\}|\$\(.*\)/.test(operation) || /\*\*?/.test(operation)) return true;
210
+ if (/^Bash:\s*\.\//.test(operation)) return true;
211
+ }
206
212
 
207
213
  return false;
208
214
  }
@@ -15,20 +15,20 @@ export function createImproviseRoutes(workingDir: string) {
15
15
 
16
16
  routes.get('/sessions', async (c) => {
17
17
  try {
18
- const sessionsDir = join(workingDir, '.mstro', 'improvise')
18
+ const sessionsDir = join(workingDir, '.mstro', 'history')
19
19
  const { readdirSync, existsSync, readFileSync } = await import('node:fs')
20
20
 
21
21
  if (!existsSync(sessionsDir)) {
22
22
  return c.json({ sessions: [] })
23
23
  }
24
24
 
25
- // Look for history-*.json files in the improvise directory
25
+ // Look for *.json files in the history directory
26
26
  const historyFiles = readdirSync(sessionsDir)
27
- .filter((name: string) => name.startsWith('history-') && name.endsWith('.json'))
27
+ .filter((name: string) => name.endsWith('.json'))
28
28
  .sort((a: string, b: string) => {
29
29
  // Sort by timestamp in filename (newer first)
30
- const timestampA = parseInt(a.replace('history-', '').replace('.json', ''), 10)
31
- const timestampB = parseInt(b.replace('history-', '').replace('.json', ''), 10)
30
+ const timestampA = parseInt(a.replace('.json', ''), 10)
31
+ const timestampB = parseInt(b.replace('.json', ''), 10)
32
32
  return timestampB - timestampA
33
33
  })
34
34
 
@@ -64,7 +64,7 @@ export function createImproviseRoutes(workingDir: string) {
64
64
  const { sessionId } = c.req.param()
65
65
  // Extract timestamp from sessionId (e.g., "improv-1234567890" -> "1234567890")
66
66
  const timestamp = sessionId.replace('improv-', '')
67
- const historyPath = join(workingDir, '.mstro', 'improvise', `history-${timestamp}.json`)
67
+ const historyPath = join(workingDir, '.mstro', 'history', `${timestamp}.json`)
68
68
  const { existsSync, readFileSync } = await import('node:fs')
69
69
 
70
70
  if (!existsSync(historyPath)) {
@@ -109,11 +109,15 @@ export async function initAnalytics(): Promise<void> {
109
109
  }
110
110
 
111
111
  client = new PostHog(analyticsConfig.posthogKey, {
112
- host: analyticsConfig.posthogHost,
113
- // Flush events every 10 seconds or 20 events
112
+ // Route through platform server proxy (like web/ does) to avoid
113
+ // direct PostHog DNS lookups and ad-blocker/firewall issues
114
+ host: `${PLATFORM_URL}/a`,
114
115
  flushAt: 20,
115
116
  flushInterval: 10000,
116
117
  })
118
+
119
+ // Silently swallow analytics errors — never surface to user terminal
120
+ client.on('error', () => {})
117
121
  }
118
122
 
119
123
  /**
@@ -122,7 +126,11 @@ export async function initAnalytics(): Promise<void> {
122
126
  */
123
127
  export async function shutdownAnalytics(): Promise<void> {
124
128
  if (client) {
125
- await client.shutdown()
129
+ try {
130
+ await client.shutdown()
131
+ } catch {
132
+ // Ignore shutdown errors (network may be unavailable)
133
+ }
126
134
  client = null
127
135
  }
128
136
  }
@@ -263,6 +271,8 @@ export const AnalyticsEvents = {
263
271
  IMPROVISE_MOVEMENT_ERROR: 'improvise_movement_error',
264
272
  IMPROVISE_SESSION_ENDED: 'improvise_session_ended',
265
273
  IMPROVISE_ABORTED: 'improvise_aborted',
274
+ IMPROVISE_TOOL_TIMEOUT: 'improvise_tool_timeout',
275
+ IMPROVISE_AUTO_RETRY: 'improvise_auto_retry',
266
276
 
267
277
  // Terminal events
268
278
  TERMINAL_SESSION_CREATED: 'terminal_session_created',
@@ -31,10 +31,6 @@ const mockClientId = {
31
31
  getClientId: vi.fn(),
32
32
  }
33
33
 
34
- const mockTmux = {
35
- isTmuxAvailable: vi.fn(),
36
- }
37
-
38
34
  // Mock fetch globally
39
35
  global.fetch = vi.fn()
40
36
 
@@ -115,7 +111,6 @@ vi.mock('fs', () => mockFs)
115
111
  vi.mock('os', () => mockOs)
116
112
  vi.mock('path', () => mockPath)
117
113
  vi.mock('./client-id.js', () => mockClientId)
118
- vi.mock('./terminal/tmux-manager.js', () => mockTmux)
119
114
 
120
115
  // Mock undici WebSocket for Node 18-20 compatibility
121
116
  vi.mock('undici', () => ({
@@ -150,8 +145,6 @@ describe('Platform Connection Service', () => {
150
145
  mockOs.type.mockReturnValue('Linux')
151
146
  mockOs.arch.mockReturnValue('x64')
152
147
  mockClientId.getClientId.mockReturnValue('test-client-id-123')
153
- mockTmux.isTmuxAvailable.mockReturnValue(true)
154
-
155
148
  // Mock process.version
156
149
  Object.defineProperty(process, 'version', {
157
150
  value: 'v22.0.0',
@@ -410,14 +403,11 @@ describe('Platform Connection Service', () => {
410
403
  })
411
404
  )
412
405
 
413
- mockTmux.isTmuxAvailable.mockReturnValue(true)
414
-
415
406
  const connection = new PlatformConnection('/test/dir')
416
407
  connection.connect()
417
408
 
418
409
  const wsUrl = WebSocketConstructor.mock.calls[0][0]
419
410
  expect(wsUrl).toContain('capabilities=')
420
- expect(wsUrl).toContain('tmux')
421
411
  })
422
412
 
423
413
  it('should use custom platform URL when provided', () => {
@@ -20,7 +20,6 @@ import { basename, join } from 'node:path'
20
20
  import { AnalyticsEvents, trackEvent } from './analytics.js'
21
21
  import { getClientId } from './client-id.js'
22
22
  import { captureException } from './sentry.js'
23
- import { isTmuxAvailable } from './terminal/tmux-manager.js'
24
23
 
25
24
  const MSTRO_DIR = join(homedir(), '.mstro')
26
25
  const CREDENTIALS_FILE = join(MSTRO_DIR, 'credentials.json')
@@ -242,9 +241,6 @@ export class PlatformConnection {
242
241
  return
243
242
  }
244
243
 
245
- // Check for tmux availability (for persistent terminals)
246
- const hasTmux = isTmuxAvailable()
247
-
248
244
  // Build URL params WITHOUT the auth token — token is sent post-connection
249
245
  // to avoid leaking it in proxy logs, browser history, and server access logs
250
246
  const params = new URLSearchParams({
@@ -256,7 +252,7 @@ export class PlatformConnection {
256
252
  nodeVersion,
257
253
  osType,
258
254
  cpuArch,
259
- capabilities: JSON.stringify({ tmux: hasTmux })
255
+ capabilities: JSON.stringify({})
260
256
  })
261
257
 
262
258
  const wsUrl = `${this.platformUrl.replace(/^http/, 'ws')}/ws/client?${params}`
@@ -284,7 +280,7 @@ export class PlatformConnection {
284
280
 
285
281
  this.ws.onopen = () => {
286
282
  clearTimeout(connectionTimeout)
287
- console.log(`🌐 Connected to platform`)
283
+ // Platform WebSocket open — auth will follow
288
284
 
289
285
  // Send auth token as first message instead of URL param
290
286
  this.ws!.send(JSON.stringify({ type: 'auth', token: authToken }))
@@ -332,7 +328,7 @@ export class PlatformConnection {
332
328
  return
333
329
  }
334
330
 
335
- console.log('Disconnected from platform, reconnecting...')
331
+ console.log('Disconnected, reconnecting...')
336
332
  this.callbacks.onDisconnected?.()
337
333
  trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED)
338
334
  this.scheduleReconnect()
@@ -349,20 +345,18 @@ export class PlatformConnection {
349
345
  case 'paired':
350
346
  this.isConnected = true
351
347
  this.connectionId = message.connectionId
352
- console.log(`⚡ Connected to mstro.app!`)
348
+ // Connection status printed by onConnected callback
353
349
  // Start heartbeat to keep server-side TTL refreshed
354
350
  this.startHeartbeat()
355
351
  this.callbacks.onConnected?.(message.connectionId)
356
352
  break
357
353
 
358
354
  case 'web_connected':
359
- console.log('🔗 Web client connected')
360
355
  this.callbacks.onWebConnected?.()
361
356
  trackEvent(AnalyticsEvents.WEB_CLIENT_CONNECTED)
362
357
  break
363
358
 
364
359
  case 'web_disconnected':
365
- console.log('🔗 Web client disconnected')
366
360
  this.callbacks.onWebDisconnected?.()
367
361
  trackEvent(AnalyticsEvents.WEB_CLIENT_DISCONNECTED)
368
362
  break
@@ -0,0 +1,78 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Sandbox Utilities
6
+ *
7
+ * Environment sanitization for sandboxed shared sessions.
8
+ * Used by both PTY manager (terminal) and Claude invoker (prompts)
9
+ * to restrict shared users to the project directory.
10
+ */
11
+
12
+ /** Env var prefixes that may contain secrets or grant access outside the project */
13
+ const BLOCKED_PREFIXES = [
14
+ 'AWS_',
15
+ 'GITHUB_',
16
+ 'GH_',
17
+ 'NPM_',
18
+ 'DOCKER_',
19
+ 'SSH_',
20
+ 'GPG_',
21
+ 'AZURE_',
22
+ 'GCP_',
23
+ 'GOOGLE_',
24
+ 'OPENAI_',
25
+ 'ANTHROPIC_',
26
+ 'STRIPE_',
27
+ 'TWILIO_',
28
+ 'SENDGRID_',
29
+ 'DATADOG_',
30
+ 'SENTRY_',
31
+ 'SLACK_',
32
+ 'DISCORD_',
33
+ ];
34
+
35
+ /** Specific env vars that may contain secrets or sensitive paths */
36
+ const BLOCKED_KEYS = new Set([
37
+ 'HISTFILE',
38
+ 'LESSHISTFILE',
39
+ 'MYSQL_PWD',
40
+ 'PGPASSWORD',
41
+ 'PGPASSFILE',
42
+ 'REDIS_URL',
43
+ 'DATABASE_URL',
44
+ 'MONGO_URI',
45
+ 'MONGODB_URI',
46
+ 'SECRET_KEY',
47
+ 'API_KEY',
48
+ 'API_SECRET',
49
+ 'ACCESS_TOKEN',
50
+ 'REFRESH_TOKEN',
51
+ 'PRIVATE_KEY',
52
+ 'JWT_SECRET',
53
+ ]);
54
+
55
+ /**
56
+ * Create a sanitized environment for sandboxed execution.
57
+ * Strips sensitive env vars and sets HOME to the project directory.
58
+ */
59
+ export function sanitizeEnvForSandbox(
60
+ env: NodeJS.ProcessEnv,
61
+ workingDir: string
62
+ ): Record<string, string> {
63
+ const result: Record<string, string> = {};
64
+
65
+ for (const [key, value] of Object.entries(env)) {
66
+ if (!value) continue;
67
+ if (BLOCKED_KEYS.has(key)) continue;
68
+ if (BLOCKED_PREFIXES.some(p => key.startsWith(p))) continue;
69
+ result[key] = value;
70
+ }
71
+
72
+ // Override HOME to project directory so `cd ~` stays sandboxed
73
+ result.HOME = workingDir;
74
+ // Marker so scripts can detect sandboxed execution
75
+ result.MSTRO_SANDBOXED = '1';
76
+
77
+ return result;
78
+ }
@@ -26,6 +26,8 @@ export interface MstroSettings {
26
26
  * - Any other string is passed as --model <value>
27
27
  */
28
28
  model: string
29
+ /** Per-repo preferred PR base branch, keyed by normalized remote URL */
30
+ prBaseBranches?: Record<string, string>
29
31
  }
30
32
 
31
33
  const DEFAULT_SETTINGS: MstroSettings = {
@@ -87,3 +89,26 @@ export function setModel(model: string): void {
87
89
  settings.model = model
88
90
  saveSettings(settings)
89
91
  }
92
+
93
+ /** Normalize a remote URL into a stable key (e.g. "github.com/owner/repo") */
94
+ function normalizeRemoteUrl(remoteUrl: string): string {
95
+ return remoteUrl
96
+ .replace(/^(https?:\/\/|git@)/, '')
97
+ .replace(/\.git$/, '')
98
+ .replace(/:/, '/')
99
+ }
100
+
101
+ /** Get the preferred PR base branch for a repo */
102
+ export function getPrBaseBranch(remoteUrl: string): string | null {
103
+ const settings = getSettings()
104
+ const key = normalizeRemoteUrl(remoteUrl)
105
+ return settings.prBaseBranches?.[key] ?? null
106
+ }
107
+
108
+ /** Save the preferred PR base branch for a repo */
109
+ export function setPrBaseBranch(remoteUrl: string, branch: string): void {
110
+ const settings = getSettings()
111
+ if (!settings.prBaseBranches) settings.prBaseBranches = {}
112
+ settings.prBaseBranches[normalizeRemoteUrl(remoteUrl)] = branch
113
+ saveSettings(settings)
114
+ }
@@ -12,15 +12,13 @@
12
12
  * - Scrollback buffer is maintained for replay on reconnect
13
13
  * - Sessions can be reattached without losing running processes
14
14
  *
15
- * Also supports tmux-backed persistence for sessions that survive server restarts.
16
- *
17
15
  * NOTE: node-pty is an optional dependency requiring native compilation.
18
16
  * Terminal features gracefully degrade when node-pty is not available.
19
17
  */
20
18
 
21
19
  import { EventEmitter } from 'node:events';
22
20
  import { homedir, platform } from 'node:os';
23
- import { getTmuxManager, isTmuxAvailable, type TmuxSession } from './tmux-manager.js';
21
+ import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
24
22
 
25
23
  // Try to load node-pty (optional native dependency)
26
24
  let pty: typeof import('node-pty') | null = null;
@@ -74,11 +72,6 @@ export function getPtyInstallInstructions(): string {
74
72
  return instructions;
75
73
  }
76
74
 
77
- // Maximum lines to store in scrollback buffer per terminal
78
- const MAX_SCROLLBACK_LINES = 5000;
79
- // Maximum characters per line to prevent memory bloat
80
- const MAX_LINE_LENGTH = 2000;
81
-
82
75
  // Import type separately for type-checking (doesn't require the module to load)
83
76
  type IPty = import('node-pty').IPty;
84
77
 
@@ -87,8 +80,6 @@ export interface PTYSession {
87
80
  pty: IPty;
88
81
  shell: string;
89
82
  cwd: string;
90
- // Scrollback buffer for replay on reconnect
91
- scrollback: string[];
92
83
  // Timestamp when session was created
93
84
  createdAt: number;
94
85
  // Last activity timestamp
@@ -156,41 +147,6 @@ export class PTYManager extends EventEmitter {
156
147
  };
157
148
  }
158
149
 
159
- /**
160
- * Get scrollback buffer for replay on reconnect
161
- * Returns the stored output history
162
- */
163
- getScrollback(terminalId: string): string[] {
164
- const session = this.terminals.get(terminalId);
165
- if (!session) return [];
166
- return [...session.scrollback];
167
- }
168
-
169
- /**
170
- * Add data to scrollback buffer
171
- * Maintains a rolling buffer of recent terminal output
172
- */
173
- private addToScrollback(session: PTYSession, data: string): void {
174
- // Split data into lines
175
- const lines = data.split(/\r?\n/);
176
-
177
- for (const line of lines) {
178
- // Truncate very long lines to prevent memory issues
179
- const truncatedLine = line.length > MAX_LINE_LENGTH
180
- ? `${line.slice(0, MAX_LINE_LENGTH)}...`
181
- : line;
182
-
183
- session.scrollback.push(truncatedLine);
184
- }
185
-
186
- // Trim buffer if it exceeds max size
187
- if (session.scrollback.length > MAX_SCROLLBACK_LINES) {
188
- session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_LINES);
189
- }
190
-
191
- session.lastActivityAt = Date.now();
192
- }
193
-
194
150
  /**
195
151
  * Check if PTY functionality is available
196
152
  */
@@ -213,7 +169,8 @@ export class PTYManager extends EventEmitter {
213
169
  workingDir: string,
214
170
  cols: number = 80,
215
171
  rows: number = 24,
216
- requestedShell?: string
172
+ requestedShell?: string,
173
+ options?: { sandboxed?: boolean }
217
174
  ): { shell: string; cwd: string; isReconnect: boolean } {
218
175
  // Check if node-pty is available
219
176
  if (!pty) {
@@ -242,28 +199,30 @@ export class PTYManager extends EventEmitter {
242
199
 
243
200
 
244
201
  try {
202
+ // Build env: sandboxed sessions get stripped secrets and HOME=projectDir
203
+ const baseEnv = options?.sandboxed
204
+ ? sanitizeEnvForSandbox(process.env, cwd)
205
+ : { ...process.env, HOME: homedir() };
206
+ const env = {
207
+ ...baseEnv,
208
+ TERM: 'xterm-256color',
209
+ COLORTERM: 'truecolor',
210
+ };
211
+
245
212
  // Spawn the PTY process
246
213
  const ptyProcess = pty.spawn(shell, [], {
247
214
  name: 'xterm-256color',
248
215
  cols,
249
216
  rows,
250
217
  cwd,
251
- env: {
252
- ...process.env,
253
- TERM: 'xterm-256color',
254
- COLORTERM: 'truecolor',
255
- // Ensure home directory is set
256
- HOME: homedir(),
257
- },
218
+ env,
258
219
  });
259
220
 
260
- // Store the session with scrollback buffer
261
221
  const session: PTYSession = {
262
222
  id: terminalId,
263
223
  pty: ptyProcess,
264
224
  shell: getShellName(shell),
265
225
  cwd,
266
- scrollback: [],
267
226
  createdAt: Date.now(),
268
227
  lastActivityAt: Date.now(),
269
228
  cols,
@@ -271,9 +230,9 @@ export class PTYManager extends EventEmitter {
271
230
  };
272
231
  this.terminals.set(terminalId, session);
273
232
 
274
- // Handle data output - store in scrollback and emit
233
+ // Handle data output
275
234
  ptyProcess.onData((data: string) => {
276
- this.addToScrollback(session, data);
235
+ session.lastActivityAt = Date.now();
277
236
  this.emit('output', terminalId, data);
278
237
  });
279
238
 
@@ -381,76 +340,6 @@ export class PTYManager extends EventEmitter {
381
340
  }
382
341
  }
383
342
 
384
- /**
385
- * Check if tmux persistence is available
386
- */
387
- isTmuxAvailable(): boolean {
388
- return isTmuxAvailable();
389
- }
390
-
391
- /**
392
- * Get list of persistent tmux sessions that can be restored
393
- * These are sessions that survived a server restart
394
- */
395
- getPersistentSessions(): TmuxSession[] {
396
- const tmux = getTmuxManager();
397
- return tmux.getActiveSessions();
398
- }
399
-
400
- /**
401
- * Create a persistent (tmux-backed) terminal session
402
- * These sessions survive server restarts
403
- */
404
- createPersistent(
405
- terminalId: string,
406
- workingDir: string,
407
- cols: number = 80,
408
- rows: number = 24,
409
- requestedShell?: string
410
- ): { shell: string; cwd: string; isReconnect: boolean; persistent: true } {
411
- const tmux = getTmuxManager();
412
-
413
- if (!tmux.isAvailable()) {
414
- throw new Error('tmux is not available for persistent sessions');
415
- }
416
-
417
- const result = tmux.create(terminalId, workingDir, cols, rows, requestedShell);
418
- return { ...result, persistent: true };
419
- }
420
-
421
- /**
422
- * Attach to a persistent (tmux) session
423
- * Returns handlers for write, resize, and detach
424
- */
425
- attachPersistent(
426
- terminalId: string,
427
- onOutput: (data: string) => void,
428
- onExit: (code: number) => void
429
- ): { write: (data: string) => void; resize: (cols: number, rows: number) => void; detach: () => void } | null {
430
- const tmux = getTmuxManager();
431
-
432
- if (!tmux.exists(terminalId)) {
433
- return null;
434
- }
435
-
436
- return tmux.attach(terminalId, onOutput, onExit);
437
- }
438
-
439
- /**
440
- * Get scrollback from a persistent (tmux) session
441
- */
442
- getPersistentScrollback(terminalId: string): string[] {
443
- const tmux = getTmuxManager();
444
- return tmux.getScrollback(terminalId);
445
- }
446
-
447
- /**
448
- * Close a persistent (tmux) session
449
- */
450
- closePersistent(terminalId: string): boolean {
451
- const tmux = getTmuxManager();
452
- return tmux.close(terminalId);
453
- }
454
343
  }
455
344
 
456
345
  // Singleton instance