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.
- package/bin/commands/login.js +27 -14
- package/bin/commands/logout.js +35 -1
- package/bin/commands/status.js +1 -1
- package/bin/mstro.js +5 -108
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +432 -103
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/index.d.ts +2 -1
- package/dist/server/cli/headless/index.d.ts.map +1 -1
- package/dist/server/cli/headless/index.js +2 -0
- package/dist/server/cli/headless/index.js.map +1 -1
- package/dist/server/cli/headless/prompt-utils.d.ts +5 -8
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -1
- package/dist/server/cli/headless/prompt-utils.js +40 -5
- package/dist/server/cli/headless/prompt-utils.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +29 -7
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +77 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +336 -20
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +67 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -0
- package/dist/server/cli/headless/tool-watchdog.js +296 -0
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +80 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +109 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +737 -132
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +5 -10
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +18 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +2 -2
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js +12 -8
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/security-patterns.d.ts.map +1 -1
- package/dist/server/mcp/security-patterns.js +9 -4
- package/dist/server/mcp/security-patterns.js.map +1 -1
- package/dist/server/routes/improvise.js +6 -6
- package/dist/server/routes/improvise.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -0
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -3
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +4 -9
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +6 -0
- package/dist/server/services/sandbox-utils.d.ts.map +1 -0
- package/dist/server/services/sandbox-utils.js +72 -0
- package/dist/server/services/sandbox-utils.js.map +1 -0
- package/dist/server/services/settings.d.ts +6 -0
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +21 -0
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +3 -51
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +14 -100
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +36 -15
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +452 -223
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +6 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/hooks/bouncer.sh +11 -4
- package/package.json +4 -1
- package/server/cli/headless/claude-invoker.ts +602 -119
- package/server/cli/headless/index.ts +7 -1
- package/server/cli/headless/prompt-utils.ts +37 -5
- package/server/cli/headless/runner.ts +30 -8
- package/server/cli/headless/stall-assessor.ts +453 -22
- package/server/cli/headless/tool-watchdog.ts +390 -0
- package/server/cli/headless/types.ts +84 -1
- package/server/cli/improvisation-session-manager.ts +884 -143
- package/server/index.ts +5 -10
- package/server/mcp/bouncer-integration.ts +28 -0
- package/server/mcp/security-audit.ts +12 -8
- package/server/mcp/security-patterns.ts +8 -2
- package/server/routes/improvise.ts +6 -6
- package/server/services/analytics.ts +13 -3
- package/server/services/platform.test.ts +0 -10
- package/server/services/platform.ts +4 -10
- package/server/services/sandbox-utils.ts +78 -0
- package/server/services/settings.ts +25 -0
- package/server/services/terminal/pty-manager.ts +16 -127
- package/server/services/websocket/handler.ts +515 -251
- package/server/services/websocket/types.ts +10 -4
- package/dist/server/services/terminal/tmux-manager.d.ts +0 -82
- package/dist/server/services/terminal/tmux-manager.d.ts.map +0 -1
- package/dist/server/services/terminal/tmux-manager.js +0 -352
- package/dist/server/services/terminal/tmux-manager.js.map +0 -1
- 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
|
-
|
|
346
|
-
|
|
347
|
-
console.log(`
|
|
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(
|
|
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
|
|
14
|
-
const
|
|
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(
|
|
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
|
|
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
|
-
|
|
205
|
-
if (/^Bash
|
|
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', '
|
|
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
|
|
25
|
+
// Look for *.json files in the history directory
|
|
26
26
|
const historyFiles = readdirSync(sessionsDir)
|
|
27
|
-
.filter((name: string) => name.
|
|
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('
|
|
31
|
-
const timestampB = parseInt(b.replace('
|
|
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', '
|
|
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
|
-
|
|
113
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
233
|
+
// Handle data output
|
|
275
234
|
ptyProcess.onData((data: string) => {
|
|
276
|
-
|
|
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
|