mstro-app 0.4.16 → 0.4.20

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 (162) hide show
  1. package/README.md +148 -75
  2. package/dist/server/cli/headless/claude-invoker-process.d.ts +1 -1
  3. package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-process.js +4 -10
  5. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.d.ts +7 -2
  9. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  10. package/dist/server/cli/headless/mcp-config.js +28 -4
  11. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  12. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  13. package/dist/server/cli/headless/runner.js +0 -1
  14. package/dist/server/cli/headless/runner.js.map +1 -1
  15. package/dist/server/cli/headless/types.d.ts +1 -4
  16. package/dist/server/cli/headless/types.d.ts.map +1 -1
  17. package/dist/server/cli/improvisation-retry.d.ts +1 -1
  18. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  19. package/dist/server/cli/improvisation-retry.js +1 -2
  20. package/dist/server/cli/improvisation-retry.js.map +1 -1
  21. package/dist/server/cli/improvisation-session-manager.d.ts +0 -1
  22. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  23. package/dist/server/cli/improvisation-session-manager.js +44 -9
  24. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  25. package/dist/server/index.js +17 -2
  26. package/dist/server/index.js.map +1 -1
  27. package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
  28. package/dist/server/mcp/bouncer-haiku.js +10 -5
  29. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  30. package/dist/server/mcp/bouncer-integration.d.ts +3 -1
  31. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  32. package/dist/server/mcp/bouncer-integration.js +16 -5
  33. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  34. package/dist/server/mcp/server.js +3 -1
  35. package/dist/server/mcp/server.js.map +1 -1
  36. package/dist/server/services/plan/composer.d.ts +1 -1
  37. package/dist/server/services/plan/composer.d.ts.map +1 -1
  38. package/dist/server/services/plan/composer.js +2 -3
  39. package/dist/server/services/plan/composer.js.map +1 -1
  40. package/dist/server/services/plan/executor.d.ts +0 -3
  41. package/dist/server/services/plan/executor.d.ts.map +1 -1
  42. package/dist/server/services/plan/executor.js +1 -8
  43. package/dist/server/services/plan/executor.js.map +1 -1
  44. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  45. package/dist/server/services/plan/review-gate.js +19 -2
  46. package/dist/server/services/plan/review-gate.js.map +1 -1
  47. package/dist/server/services/plan/state-reconciler.d.ts +6 -0
  48. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  49. package/dist/server/services/plan/state-reconciler.js +68 -1
  50. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  51. package/dist/server/services/platform.d.ts.map +1 -1
  52. package/dist/server/services/platform.js +17 -4
  53. package/dist/server/services/platform.js.map +1 -1
  54. package/dist/server/services/terminal/pty-manager.d.ts +2 -4
  55. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  56. package/dist/server/services/terminal/pty-manager.js +4 -8
  57. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  58. package/dist/server/services/terminal/pty-utils.d.ts +2 -2
  59. package/dist/server/services/terminal/pty-utils.d.ts.map +1 -1
  60. package/dist/server/services/terminal/pty-utils.js +2 -2
  61. package/dist/server/services/terminal/pty-utils.js.map +1 -1
  62. package/dist/server/services/websocket/autocomplete.d.ts +1 -1
  63. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -1
  64. package/dist/server/services/websocket/autocomplete.js +37 -24
  65. package/dist/server/services/websocket/autocomplete.js.map +1 -1
  66. package/dist/server/services/websocket/file-explorer-handlers.d.ts +2 -2
  67. package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -1
  68. package/dist/server/services/websocket/file-explorer-handlers.js +11 -4
  69. package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
  70. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  71. package/dist/server/services/websocket/handler.js +6 -1
  72. package/dist/server/services/websocket/handler.js.map +1 -1
  73. package/dist/server/services/websocket/plan-board-handlers.d.ts +5 -5
  74. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  75. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  76. package/dist/server/services/websocket/plan-execution-handlers.d.ts +6 -6
  77. package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
  78. package/dist/server/services/websocket/plan-execution-handlers.js +1 -4
  79. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  80. package/dist/server/services/websocket/plan-handlers.d.ts +1 -1
  81. package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
  82. package/dist/server/services/websocket/plan-handlers.js.map +1 -1
  83. package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
  84. package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
  85. package/dist/server/services/websocket/plan-helpers.js.map +1 -1
  86. package/dist/server/services/websocket/plan-issue-handlers.d.ts +4 -4
  87. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  88. package/dist/server/services/websocket/plan-issue-handlers.js +10 -0
  89. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  90. package/dist/server/services/websocket/plan-sprint-handlers.d.ts +3 -3
  91. package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
  92. package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
  93. package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
  94. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  95. package/dist/server/services/websocket/quality-handlers.js +9 -5
  96. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  97. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  98. package/dist/server/services/websocket/quality-review-agent.js +7 -4
  99. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  100. package/dist/server/services/websocket/session-handlers.d.ts +1 -1
  101. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  102. package/dist/server/services/websocket/session-handlers.js +5 -2
  103. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  104. package/dist/server/services/websocket/terminal-handlers.d.ts +1 -1
  105. package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -1
  106. package/dist/server/services/websocket/terminal-handlers.js +6 -5
  107. package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
  108. package/dist/server/services/websocket/types.d.ts +2 -2
  109. package/dist/server/services/websocket/types.d.ts.map +1 -1
  110. package/dist/server/utils/port.d.ts +0 -11
  111. package/dist/server/utils/port.d.ts.map +1 -1
  112. package/dist/server/utils/port.js +0 -31
  113. package/dist/server/utils/port.js.map +1 -1
  114. package/package.json +1 -2
  115. package/server/cli/headless/claude-invoker-process.ts +5 -12
  116. package/server/cli/headless/claude-invoker.ts +1 -1
  117. package/server/cli/headless/mcp-config.ts +31 -4
  118. package/server/cli/headless/runner.ts +0 -1
  119. package/server/cli/headless/types.ts +1 -4
  120. package/server/cli/improvisation-retry.ts +0 -2
  121. package/server/cli/improvisation-session-manager.ts +45 -10
  122. package/server/index.ts +16 -2
  123. package/server/mcp/bouncer-haiku.ts +11 -5
  124. package/server/mcp/bouncer-integration.ts +14 -5
  125. package/server/mcp/server.ts +3 -1
  126. package/server/services/plan/composer.ts +1 -3
  127. package/server/services/plan/executor.ts +1 -9
  128. package/server/services/plan/review-gate.ts +13 -2
  129. package/server/services/plan/state-reconciler.ts +70 -1
  130. package/server/services/platform.ts +16 -4
  131. package/server/services/terminal/pty-manager.ts +5 -10
  132. package/server/services/terminal/pty-utils.ts +2 -2
  133. package/server/services/websocket/autocomplete.ts +48 -26
  134. package/server/services/websocket/file-explorer-handlers.ts +14 -7
  135. package/server/services/websocket/handler.ts +8 -2
  136. package/server/services/websocket/plan-board-handlers.ts +5 -5
  137. package/server/services/websocket/plan-execution-handlers.ts +7 -10
  138. package/server/services/websocket/plan-handlers.ts +1 -1
  139. package/server/services/websocket/plan-helpers.ts +1 -1
  140. package/server/services/websocket/plan-issue-handlers.ts +14 -4
  141. package/server/services/websocket/plan-sprint-handlers.ts +3 -3
  142. package/server/services/websocket/quality-handlers.ts +9 -5
  143. package/server/services/websocket/quality-review-agent.ts +7 -4
  144. package/server/services/websocket/session-handlers.ts +8 -3
  145. package/server/services/websocket/terminal-handlers.ts +7 -8
  146. package/server/services/websocket/types.ts +2 -2
  147. package/server/utils/port.ts +0 -41
  148. package/dist/server/mcp/bouncer-sandbox.d.ts +0 -60
  149. package/dist/server/mcp/bouncer-sandbox.d.ts.map +0 -1
  150. package/dist/server/mcp/bouncer-sandbox.js +0 -182
  151. package/dist/server/mcp/bouncer-sandbox.js.map +0 -1
  152. package/dist/server/services/credentials.d.ts +0 -39
  153. package/dist/server/services/credentials.d.ts.map +0 -1
  154. package/dist/server/services/credentials.js +0 -110
  155. package/dist/server/services/credentials.js.map +0 -1
  156. package/dist/server/services/sandbox-utils.d.ts +0 -8
  157. package/dist/server/services/sandbox-utils.d.ts.map +0 -1
  158. package/dist/server/services/sandbox-utils.js +0 -75
  159. package/dist/server/services/sandbox-utils.js.map +0 -1
  160. package/server/mcp/bouncer-sandbox.ts +0 -214
  161. package/server/services/credentials.ts +0 -134
  162. package/server/services/sandbox-utils.ts +0 -82
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, statSync } from 'node:fs';
11
- import { join, } from 'node:path';
11
+ import { join, normalize, resolve } from 'node:path';
12
12
  import Fuse, { type FuseResult } from 'fuse.js';
13
13
  import {
14
- CACHE_TTL_MS,
14
+ CACHE_TTL_MS,
15
15
  directoryCache,
16
16
  getFileType,
17
17
  isIgnored,
@@ -100,6 +100,30 @@ function shouldIncludeEntry(
100
100
  return true;
101
101
  }
102
102
 
103
+ interface PathScope {
104
+ scopedDir: string;
105
+ searchQuery: string;
106
+ pathPrefix: string;
107
+ maxDepth: number;
108
+ }
109
+
110
+ /** Parse a partial path into its directory scope, search query, and depth limit. */
111
+ function resolvePathScope(cleanPath: string, workingDir: string, sandboxed?: boolean): PathScope | null {
112
+ const lastSlashIndex = cleanPath.lastIndexOf('/');
113
+ if (lastSlashIndex === -1) {
114
+ return { scopedDir: workingDir, searchQuery: cleanPath, pathPrefix: '', maxDepth: cleanPath === '' ? 4 : 10 };
115
+ }
116
+
117
+ const dirPath = cleanPath.substring(0, lastSlashIndex);
118
+ if (sandboxed && !isPathWithinDir(dirPath, workingDir)) return null;
119
+
120
+ const candidateDir = join(workingDir, dirPath);
121
+ if (existsSync(candidateDir) && statSync(candidateDir).isDirectory()) {
122
+ return { scopedDir: candidateDir, searchQuery: cleanPath.substring(lastSlashIndex + 1), pathPrefix: `${dirPath}/`, maxDepth: 3 };
123
+ }
124
+ return { scopedDir: workingDir, searchQuery: cleanPath, pathPrefix: '', maxDepth: 10 };
125
+ }
126
+
103
127
  export class AutocompleteService {
104
128
  private frecencyData: FrecencyData = {};
105
129
 
@@ -175,12 +199,18 @@ export class AutocompleteService {
175
199
  /**
176
200
  * Get file completions for autocomplete with directory-scoped navigation
177
201
  */
178
- getFileCompletions(partialPath: string, workingDir: string): AutocompleteResult[] {
202
+ getFileCompletions(partialPath: string, workingDir: string, sandboxed?: boolean): AutocompleteResult[] {
179
203
  try {
180
204
  // Handle @ symbol prefix for file autocomplete
181
205
  const isAtSymbol = partialPath.startsWith('@');
182
206
  const cleanPath = isAtSymbol ? partialPath.substring(1) : partialPath;
183
207
 
208
+ // Sandboxed users: block path traversal outside the working directory.
209
+ // Resolves the target path and checks it stays within workingDir boundaries.
210
+ if (sandboxed && cleanPath && !isPathWithinDir(cleanPath, workingDir)) {
211
+ return [];
212
+ }
213
+
184
214
  // Parse .gitignore patterns
185
215
  const gitignorePatterns = parseGitignore(workingDir);
186
216
 
@@ -189,28 +219,10 @@ export class AutocompleteService {
189
219
  return this.getDirectoryContentsEnhanced(cleanPath, workingDir, gitignorePatterns);
190
220
  }
191
221
 
192
- // STRICT PATH SEGMENT MATCHING
193
- const lastSlashIndex = cleanPath.lastIndexOf('/');
194
- let scopedDir = workingDir;
195
- let searchQuery = cleanPath;
196
- let pathPrefix = '';
197
- let maxDepth = 10;
198
-
199
- if (lastSlashIndex !== -1) {
200
- const dirPath = cleanPath.substring(0, lastSlashIndex);
201
- const candidateDir = join(workingDir, dirPath);
202
-
203
- if (existsSync(candidateDir) && statSync(candidateDir).isDirectory()) {
204
- scopedDir = candidateDir;
205
- searchQuery = cleanPath.substring(lastSlashIndex + 1);
206
- pathPrefix = `${dirPath}/`;
207
- maxDepth = 3;
208
- }
209
- } else if (cleanPath === '') {
210
- maxDepth = 4;
211
- }
222
+ const scope = resolvePathScope(cleanPath, workingDir, sandboxed);
223
+ if (!scope) return [];
212
224
 
213
- const filesWithMetadata = this.getFilesWithCache(scopedDir, gitignorePatterns, maxDepth, pathPrefix);
225
+ const filesWithMetadata = this.getFilesWithCache(scope.scopedDir, gitignorePatterns, scope.maxDepth, scope.pathPrefix);
214
226
 
215
227
  // Track which files are recent
216
228
  const recentFiles = new Set<string>();
@@ -220,9 +232,9 @@ export class AutocompleteService {
220
232
  }
221
233
  }
222
234
 
223
- const scoredMatches = searchQuery === ''
235
+ const scoredMatches = scope.searchQuery === ''
224
236
  ? this.scoreEmptyQuery(filesWithMetadata)
225
- : this.scoreWithQuery(filesWithMetadata, searchQuery, recentFiles);
237
+ : this.scoreWithQuery(filesWithMetadata, scope.searchQuery, recentFiles);
226
238
 
227
239
  const results: AutocompleteResult[] = scoredMatches.slice(0, 15).map(file => {
228
240
  const displayPath = file.isDirectory ? `${file.relativePath}/` : file.relativePath;
@@ -440,3 +452,13 @@ export class AutocompleteService {
440
452
  }
441
453
  }
442
454
  }
455
+
456
+ /**
457
+ * Check if a relative path resolves to a location within the working directory.
458
+ * Used to prevent path traversal (../) in sandboxed autocomplete.
459
+ */
460
+ function isPathWithinDir(relativePath: string, workingDir: string): boolean {
461
+ const resolved = normalize(resolve(workingDir, relativePath));
462
+ const normalizedWorkDir = resolve(workingDir);
463
+ return resolved === normalizedWorkDir || resolved.startsWith(`${normalizedWorkDir}/`);
464
+ }
@@ -16,24 +16,31 @@ import { readFileContent } from './file-utils.js';
16
16
  import type { HandlerContext } from './handler-context.js';
17
17
  import type { WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
18
18
 
19
- export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
19
+ export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
20
+ const isSandboxed = !!permission;
20
21
  switch (msg.type) {
21
22
  case 'autocomplete':
22
23
  if (!msg.data?.partialPath) throw new Error('Partial path is required');
23
- ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
24
+ ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir, isSandboxed || undefined) } });
24
25
  break;
25
26
  case 'readFile':
26
27
  handleReadFile(ctx, ws, msg, tabId, workingDir, permission);
27
28
  break;
28
29
  case 'recordSelection':
29
- if (msg.data?.filePath) ctx.recordFileSelection(msg.data.filePath);
30
+ if (msg.data?.filePath) {
31
+ if (isSandboxed) {
32
+ const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
33
+ if (!validation.valid) break; // Silently ignore out-of-bounds selections
34
+ }
35
+ ctx.recordFileSelection(msg.data.filePath);
36
+ }
30
37
  break;
31
38
  }
32
39
  }
33
40
 
34
- function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
41
+ function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
35
42
  if (!msg.data?.filePath) throw new Error('File path is required');
36
- const isSandboxed = permission === 'control' || permission === 'view';
43
+ const isSandboxed = !!permission;
37
44
  if (isSandboxed) {
38
45
  const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
39
46
  if (!validation.valid) {
@@ -51,8 +58,8 @@ function sendFileResult(ctx: HandlerContext, ws: WSContext, type: WebSocketRespo
51
58
  ctx.send(ws, { type, tabId, data });
52
59
  }
53
60
 
54
- export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
55
- const isSandboxed = permission === 'control' || permission === 'view';
61
+ export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
62
+ const isSandboxed = !!permission;
56
63
  const handlers: Record<string, () => void> = {
57
64
  listDirectory: () => {
58
65
  if (isSandboxed && msg.data?.dirPath) {
@@ -140,7 +140,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
140
140
  fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
141
141
  };
142
142
 
143
- private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): Promise<void> {
143
+ private async dispatchMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): Promise<void> {
144
144
  // Handle messages with custom inline logic first
145
145
  switch (msg.type) {
146
146
  case 'ping':
@@ -185,7 +185,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
185
185
  case 'session': return handleSessionMessage(this, ws, msg, tabId, permission);
186
186
  case 'history': return handleHistoryMessage(this, ws, msg, tabId, workingDir);
187
187
  case 'file': return handleFileMessage(this, ws, msg, tabId, effectiveDir, permission);
188
- case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir, permission);
188
+ case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir);
189
189
  case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, effectiveDir, permission);
190
190
  case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
191
191
  case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
@@ -233,6 +233,12 @@ export class WebSocketImproviseHandler implements HandlerContext {
233
233
  this.connections.delete(ws);
234
234
  this.allConnections.delete(ws);
235
235
  cleanupTerminalSubscribers(this, ws);
236
+
237
+ // Clean up file upload handler when no connections remain
238
+ if (this.allConnections.size === 0 && this.fileUploadHandler) {
239
+ this.fileUploadHandler.destroy();
240
+ this.fileUploadHandler = null;
241
+ }
236
242
  }
237
243
 
238
244
  send(ws: WSContext, response: WebSocketResponse): void {
@@ -16,7 +16,7 @@ import type { WebSocketMessage, WSContext } from './types.js';
16
16
 
17
17
  export function handleCreateBoard(
18
18
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
19
- workingDir: string, permission?: 'control' | 'view',
19
+ workingDir: string, permission?: 'view',
20
20
  ): void {
21
21
  if (denyIfViewOnly(ctx, ws, permission)) return;
22
22
 
@@ -99,7 +99,7 @@ paused: false
99
99
 
100
100
  export function handleUpdateBoard(
101
101
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
102
- workingDir: string, permission?: 'control' | 'view',
102
+ workingDir: string, permission?: 'view',
103
103
  ): void {
104
104
  if (denyIfViewOnly(ctx, ws, permission)) return;
105
105
 
@@ -133,7 +133,7 @@ export function handleUpdateBoard(
133
133
 
134
134
  export function handleArchiveBoard(
135
135
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
136
- workingDir: string, permission?: 'control' | 'view',
136
+ workingDir: string, permission?: 'view',
137
137
  ): void {
138
138
  if (denyIfViewOnly(ctx, ws, permission)) return;
139
139
 
@@ -204,7 +204,7 @@ export function handleGetBoardState(
204
204
 
205
205
  export function handleReorderBoards(
206
206
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
207
- workingDir: string, permission?: 'control' | 'view',
207
+ workingDir: string, permission?: 'view',
208
208
  ): void {
209
209
  if (denyIfViewOnly(ctx, ws, permission)) return;
210
210
 
@@ -229,7 +229,7 @@ export function handleReorderBoards(
229
229
 
230
230
  export function handleSetActiveBoard(
231
231
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
232
- workingDir: string, permission?: 'control' | 'view',
232
+ workingDir: string, permission?: 'view',
233
233
  ): void {
234
234
  if (denyIfViewOnly(ctx, ws, permission)) return;
235
235
 
@@ -14,7 +14,7 @@ import type { WebSocketMessage, WSContext } from './types.js';
14
14
 
15
15
  export function handlePrompt(
16
16
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
17
- workingDir: string, permission?: 'control' | 'view',
17
+ workingDir: string, permission?: 'view',
18
18
  ): void {
19
19
  if (denyIfViewOnly(ctx, ws, permission)) return;
20
20
 
@@ -24,8 +24,7 @@ export function handlePrompt(
24
24
  ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
25
25
  return;
26
26
  }
27
- const sandboxed = permission === 'control';
28
- handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, sandboxed).catch(error => {
27
+ handlePlanPrompt(ctx, ws, prompt, workingDir, boardId).catch(error => {
29
28
  ctx.send(ws, {
30
29
  type: 'planError',
31
30
  data: { error: error instanceof Error ? error.message : String(error) },
@@ -96,12 +95,11 @@ function wireExecutorEvents(executor: PlanExecutor, ctx: HandlerContext, working
96
95
 
97
96
  export function handleExecute(
98
97
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
99
- workingDir: string, permission?: 'control' | 'view',
98
+ workingDir: string, permission?: 'view',
100
99
  ): void {
101
100
  if (denyIfViewOnly(ctx, ws, permission)) return;
102
101
 
103
102
  const executor = getExecutor(workingDir);
104
- executor.setSandboxed(permission === 'control');
105
103
 
106
104
  if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
107
105
  ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
@@ -123,7 +121,7 @@ export function handleExecute(
123
121
 
124
122
  export function handleExecuteEpic(
125
123
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
126
- workingDir: string, permission?: 'control' | 'view',
124
+ workingDir: string, permission?: 'view',
127
125
  ): void {
128
126
  if (denyIfViewOnly(ctx, ws, permission)) return;
129
127
 
@@ -134,7 +132,6 @@ export function handleExecuteEpic(
134
132
  }
135
133
 
136
134
  const executor = getExecutor(workingDir);
137
- executor.setSandboxed(permission === 'control');
138
135
 
139
136
  if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
140
137
  ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
@@ -154,7 +151,7 @@ export function handleExecuteEpic(
154
151
 
155
152
  export function handlePause(
156
153
  ctx: HandlerContext, ws: WSContext,
157
- workingDir: string, permission?: 'control' | 'view',
154
+ workingDir: string, permission?: 'view',
158
155
  ): void {
159
156
  if (denyIfViewOnly(ctx, ws, permission)) return;
160
157
  const executor = executorCache.get(workingDir);
@@ -163,7 +160,7 @@ export function handlePause(
163
160
 
164
161
  export function handleStop(
165
162
  ctx: HandlerContext, ws: WSContext,
166
- workingDir: string, permission?: 'control' | 'view',
163
+ workingDir: string, permission?: 'view',
167
164
  ): void {
168
165
  if (denyIfViewOnly(ctx, ws, permission)) return;
169
166
  const executor = executorCache.get(workingDir);
@@ -172,7 +169,7 @@ export function handleStop(
172
169
 
173
170
  export function handleResume(
174
171
  ctx: HandlerContext, ws: WSContext,
175
- workingDir: string, permission?: 'control' | 'view',
172
+ workingDir: string, permission?: 'view',
176
173
  ): void {
177
174
  if (denyIfViewOnly(ctx, ws, permission)) return;
178
175
  const executor = executorCache.get(workingDir);
@@ -24,7 +24,7 @@ export function handlePlanMessage(
24
24
  msg: WebSocketMessage,
25
25
  _tabId: string,
26
26
  workingDir: string,
27
- permission?: 'control' | 'view',
27
+ permission?: 'view',
28
28
  ): void {
29
29
  const handlers: Record<string, () => void> = {
30
30
  planInit: () => handlePlanInit(ctx, ws, workingDir),
@@ -23,7 +23,7 @@ export function resolvePlanPath(workingDir: string, relativePath: string): strin
23
23
  }
24
24
 
25
25
  /** Guard for write operations — returns true if denied. */
26
- export function denyIfViewOnly(ctx: HandlerContext, ws: WSContext, permission?: 'control' | 'view'): boolean {
26
+ export function denyIfViewOnly(ctx: HandlerContext, ws: WSContext, permission?: 'view'): boolean {
27
27
  if (permission === 'view') {
28
28
  ctx.send(ws, { type: 'planError', data: { error: 'Permission denied' } });
29
29
  return true;
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
5
5
  import { basename, join } from 'node:path';
6
6
  import { replaceFrontMatterField } from '../plan/front-matter.js';
7
7
  import { defaultPmDir, getNextId, parseBoardDirectory, parsePlanDirectory, parseSingleIssue, parseSingleMilestone, parseSingleSprint, planDirExists, resolvePmDir } from '../plan/parser.js';
8
+ import { tryCompleteParentEpic } from '../plan/state-reconciler.js';
8
9
  import type { HandlerContext } from './handler-context.js';
9
10
  import { buildIssueMarkdown, denyIfViewOnly, formatYamlValue, getWatcher, resolvePlanPath, scaffoldPmDirectory } from './plan-helpers.js';
10
11
  import type { WebSocketMessage, WSContext } from './types.js';
@@ -108,7 +109,7 @@ function resolveBacklogContext(pmDir: string, workingDir: string, boardId?: stri
108
109
 
109
110
  export function handleCreateIssue(
110
111
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
111
- workingDir: string, permission?: 'control' | 'view',
112
+ workingDir: string, permission?: 'view',
112
113
  ): void {
113
114
  if (denyIfViewOnly(ctx, ws, permission)) return;
114
115
 
@@ -135,7 +136,7 @@ export function handleCreateIssue(
135
136
 
136
137
  export function handleUpdateIssue(
137
138
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
138
- workingDir: string, permission?: 'control' | 'view',
139
+ workingDir: string, permission?: 'view',
139
140
  ): void {
140
141
  if (denyIfViewOnly(ctx, ws, permission)) return;
141
142
 
@@ -166,11 +167,20 @@ export function handleUpdateIssue(
166
167
 
167
168
  const issue = parseSingleIssue(workingDir, path);
168
169
  ctx.broadcastToAll({ type: 'planIssueUpdated', data: issue });
170
+
171
+ // Auto-complete parent epic if all its children are now done
172
+ if (issue) {
173
+ const epicPath = tryCompleteParentEpic(workingDir, issue);
174
+ if (epicPath) {
175
+ const epic = parseSingleIssue(workingDir, epicPath);
176
+ if (epic) ctx.broadcastToAll({ type: 'planIssueUpdated', data: epic });
177
+ }
178
+ }
169
179
  }
170
180
 
171
181
  export function handleDeleteIssue(
172
182
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
173
- workingDir: string, permission?: 'control' | 'view',
183
+ workingDir: string, permission?: 'view',
174
184
  ): void {
175
185
  if (denyIfViewOnly(ctx, ws, permission)) return;
176
186
 
@@ -192,7 +202,7 @@ export function handleDeleteIssue(
192
202
 
193
203
  export function handleScaffold(
194
204
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
195
- workingDir: string, permission?: 'control' | 'view',
205
+ workingDir: string, permission?: 'view',
196
206
  ): void {
197
207
  if (denyIfViewOnly(ctx, ws, permission)) return;
198
208
 
@@ -60,7 +60,7 @@ function assignIssuesToSprint(workingDir: string, issues: Issue[], issueIds: str
60
60
 
61
61
  export function handleCreateSprint(
62
62
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
63
- workingDir: string, permission?: 'control' | 'view',
63
+ workingDir: string, permission?: 'view',
64
64
  ): void {
65
65
  if (denyIfViewOnly(ctx, ws, permission)) return;
66
66
 
@@ -116,7 +116,7 @@ function updateFileField(filePath: string, field: string, value: string): void {
116
116
 
117
117
  export function handleActivateSprint(
118
118
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
119
- workingDir: string, permission?: 'control' | 'view',
119
+ workingDir: string, permission?: 'view',
120
120
  ): void {
121
121
  if (denyIfViewOnly(ctx, ws, permission)) return;
122
122
 
@@ -162,7 +162,7 @@ export function handleActivateSprint(
162
162
 
163
163
  export function handleCompleteSprint(
164
164
  ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
165
- workingDir: string, permission?: 'control' | 'view',
165
+ workingDir: string, permission?: 'view',
166
166
  ): void {
167
167
  if (denyIfViewOnly(ctx, ws, permission)) return;
168
168
 
@@ -10,7 +10,7 @@
10
10
  * - quality-fix-agent.ts — AI fix prompt, progress tracking, handler
11
11
  */
12
12
 
13
- import { join } from 'node:path';
13
+ import { join, resolve } from 'node:path';
14
14
  import { validatePathWithinWorkingDir } from '../pathUtils.js';
15
15
  import type { HandlerContext } from './handler-context.js';
16
16
  import type { FindingForFix } from './quality-fix-agent.js';
@@ -36,8 +36,12 @@ function getPersistence(workingDir: string): QualityPersistence {
36
36
 
37
37
  function resolvePath(workingDir: string, dirPath?: string): string {
38
38
  if (!dirPath || dirPath === '.' || dirPath === './') return workingDir;
39
- if (dirPath.startsWith('/')) return dirPath;
40
- return join(workingDir, dirPath);
39
+ const resolved = dirPath.startsWith('/') ? dirPath : join(workingDir, dirPath);
40
+ // Ensure path is within working directory even for non-sandboxed users
41
+ const normalizedResolved = resolve(resolved);
42
+ const normalizedWorkingDir = resolve(workingDir);
43
+ if (!normalizedResolved.startsWith(normalizedWorkingDir)) return workingDir;
44
+ return normalizedResolved;
41
45
  }
42
46
 
43
47
  /**
@@ -68,9 +72,9 @@ export function handleQualityMessage(
68
72
  msg: WebSocketMessage,
69
73
  _tabId: string,
70
74
  workingDir: string,
71
- permission?: 'control' | 'view',
75
+ permission?: 'view',
72
76
  ): void {
73
- const isSandboxed = permission === 'control' || permission === 'view';
77
+ const isSandboxed = !!permission;
74
78
  const sendPathError = (path: string, error: string) => {
75
79
  ctx.send(ws, { type: 'qualityError', data: { path, error } });
76
80
  };
@@ -571,8 +571,11 @@ function persistReviewResults(
571
571
  ): import('./quality-service.js').QualityResults | null {
572
572
  const persistence = getPersistence(workingDir);
573
573
  const existingReport = persistence.loadReport(reportPath);
574
+ // CodeReviewFinding is structurally compatible with QualityFinding (category is a narrower union)
575
+ const findings = reviewResult.findings as import('./quality-types.js').QualityFinding[];
576
+ const findingsRecord = reviewResult.findings as unknown[] as Record<string, unknown>[];
574
577
  if (!existingReport) {
575
- persistence.saveCodeReview(reportPath, reviewResult.findings as unknown as Record<string, unknown>[], reviewResult.summary);
578
+ persistence.saveCodeReview(reportPath, findingsRecord, reviewResult.summary);
576
579
  return null;
577
580
  }
578
581
 
@@ -582,17 +585,17 @@ function persistReviewResults(
582
585
  ...existingReport,
583
586
  overall: reviewResult.score,
584
587
  grade: reviewResult.grade,
585
- codeReview: reviewResult.findings as unknown as typeof existingReport.codeReview,
588
+ codeReview: findings,
586
589
  scoreRationale: reviewResult.scoreRationale ?? undefined,
587
590
  };
588
591
  } else {
589
592
  updatedResults = recomputeWithAiReview(existingReport, reviewResult.findings);
590
- updatedResults = { ...updatedResults, codeReview: reviewResult.findings as unknown as typeof updatedResults.codeReview };
593
+ updatedResults = { ...updatedResults, codeReview: findings };
591
594
  }
592
595
 
593
596
  persistence.saveReport(reportPath, updatedResults);
594
597
  persistence.appendHistory(updatedResults, reportPath);
595
- persistence.saveCodeReview(reportPath, reviewResult.findings as unknown as Record<string, unknown>[], reviewResult.summary);
598
+ persistence.saveCodeReview(reportPath, findingsRecord, reviewResult.summary);
596
599
  return updatedResults;
597
600
  }
598
601
 
@@ -178,15 +178,20 @@ function mergePreUploadedAttachments(ctx: HandlerContext, tabId: string, inlineA
178
178
  return merged;
179
179
  }
180
180
 
181
- export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'control' | 'view'): void {
181
+ const WRITE_OPS = new Set(['execute', 'cancel', 'new', 'approve', 'reject']);
182
+
183
+ export function handleSessionMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, permission?: 'view'): void {
184
+ if (permission === 'view' && WRITE_OPS.has(msg.type)) {
185
+ throw new Error('View-only users cannot perform write operations');
186
+ }
187
+
182
188
  switch (msg.type) {
183
189
  case 'execute': {
184
190
  if (!msg.data?.prompt) throw new Error('Prompt is required');
185
191
  const session = requireSession(ctx, ws, tabId);
186
- const sandboxed = permission === 'control' || permission === 'view';
187
192
  const worktreeDir = ctx.gitDirectories.get(tabId);
188
193
  const attachments = mergePreUploadedAttachments(ctx, tabId, msg.data.attachments);
189
- session.executePrompt(msg.data.prompt, attachments, { sandboxed, workingDir: worktreeDir });
194
+ session.executePrompt(msg.data.prompt, attachments, { workingDir: worktreeDir });
190
195
  break;
191
196
  }
192
197
  case 'cancel': {
@@ -7,11 +7,11 @@ import { getPTYManager } from '../terminal/pty-manager.js';
7
7
  import type { HandlerContext } from './handler-context.js';
8
8
  import type { WebSocketMessage, WSContext } from './types.js';
9
9
 
10
- export function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
10
+ export async function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
11
11
  const termId = msg.terminalId || tabId;
12
12
  switch (msg.type) {
13
13
  case 'terminalInit':
14
- handleTerminalInit(ctx, ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows, permission);
14
+ await handleTerminalInit(ctx, ws, termId, workingDir, msg.data?.shell, msg.data?.cols, msg.data?.rows);
15
15
  break;
16
16
  case 'terminalReconnect':
17
17
  handleTerminalReconnect(ctx, ws, termId);
@@ -31,7 +31,7 @@ export function handleTerminalMessage(ctx: HandlerContext, ws: WSContext, msg: W
31
31
  }
32
32
  }
33
33
 
34
- function handleTerminalInit(
34
+ async function handleTerminalInit(
35
35
  ctx: HandlerContext,
36
36
  ws: WSContext,
37
37
  terminalId: string,
@@ -39,8 +39,7 @@ function handleTerminalInit(
39
39
  requestedShell?: string,
40
40
  cols?: number,
41
41
  rows?: number,
42
- permission?: 'control' | 'view'
43
- ): void {
42
+ ): Promise<void> {
44
43
  const ptyManager = getPTYManager();
45
44
 
46
45
  if (!ptyManager.isPtyAvailable()) {
@@ -59,13 +58,12 @@ function handleTerminalInit(
59
58
  setupTerminalBroadcastListeners(ctx, terminalId);
60
59
 
61
60
  try {
62
- const { shell, cwd, isReconnect, platform } = ptyManager.create(
61
+ const { shell, cwd, isReconnect, platform } = await ptyManager.create(
63
62
  terminalId,
64
63
  workingDir,
65
64
  cols || 80,
66
65
  rows || 24,
67
66
  requestedShell,
68
- { sandboxed: permission === 'control' || permission === 'view' }
69
67
  );
70
68
 
71
69
  if (!isReconnect) {
@@ -94,11 +92,12 @@ function handleTerminalInit(
94
92
  is_reconnect: isReconnect,
95
93
  });
96
94
  } catch (error: unknown) {
95
+ const errorMsg = error instanceof Error ? error.message : String(error);
97
96
  console.error(`[WebSocketImproviseHandler] Failed to create terminal:`, error);
98
97
  ctx.send(ws, {
99
98
  type: 'terminalError',
100
99
  terminalId,
101
- data: { error: (error instanceof Error ? error.message : String(error)) || 'Failed to create terminal' }
100
+ data: { error: errorMsg || 'Failed to create terminal' }
102
101
  });
103
102
  removeTerminalSubscriber(ctx, terminalId, ws);
104
103
  }
@@ -158,8 +158,8 @@ export interface WebSocketMessage {
158
158
  terminalId?: string;
159
159
  // biome-ignore lint/suspicious/noExplicitAny: message envelope carries heterogeneous payloads
160
160
  data?: any;
161
- /** Injected by server relay for sandboxed shared users (control + view) */
162
- _permission?: 'control' | 'view';
161
+ /** Injected by server relay for view-only shared users */
162
+ _permission?: 'view';
163
163
  }
164
164
 
165
165
  export interface WebSocketResponse {
@@ -59,44 +59,3 @@ export async function findAvailablePort(startPort: number, maxTries: number = 20
59
59
  }
60
60
  throw new Error(`No available ports found between ${startPort} and ${startPort + maxTries}`)
61
61
  }
62
-
63
- /**
64
- * Find an available port pair for frontend and backend
65
- * Frontend = EVEN port (3000, 3002, 3004...)
66
- * Backend = ODD port (3001, 3003, 3005...)
67
- *
68
- * Checks all candidate ports in parallel for fast detection.
69
- */
70
- export async function findAvailablePortPair(startPort: number = 3000, maxPairs: number = 20): Promise<{ frontend: number; backend: number }> {
71
- // Ensure startPort is even
72
- const basePort = startPort % 2 === 0 ? startPort : startPort + 1
73
-
74
- // Generate all candidate pairs
75
- const pairs: { frontend: number; backend: number }[] = []
76
- for (let i = 0; i < maxPairs; i++) {
77
- pairs.push({
78
- frontend: basePort + (i * 2), // 3000, 3002, 3004...
79
- backend: basePort + (i * 2) + 1 // 3001, 3003, 3005...
80
- })
81
- }
82
-
83
- // Check all ports in parallel (both frontend and backend ports)
84
- const allPorts = pairs.flatMap(p => [p.frontend, p.backend])
85
- const results = await Promise.all(
86
- allPorts.map(async (port) => ({ port, available: await isPortAvailable(port) }))
87
- )
88
-
89
- // Build a set of available ports for O(1) lookup
90
- const availablePorts = new Set(
91
- results.filter(r => r.available).map(r => r.port)
92
- )
93
-
94
- // Find first pair where both ports are available
95
- for (const pair of pairs) {
96
- if (availablePorts.has(pair.frontend) && availablePorts.has(pair.backend)) {
97
- return pair
98
- }
99
- }
100
-
101
- throw new Error(`No available port pairs found starting from ${startPort}`)
102
- }