mstro-app 0.4.17 → 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 +18 -6
  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 +5 -28
  57. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  58. package/dist/server/services/terminal/pty-utils.d.ts +2 -13
  59. package/dist/server/services/terminal/pty-utils.d.ts.map +1 -1
  60. package/dist/server/services/terminal/pty-utils.js +2 -74
  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 +9 -21
  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 +17 -6
  131. package/server/services/terminal/pty-manager.ts +6 -33
  132. package/server/services/terminal/pty-utils.ts +2 -80
  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 +10 -24
  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
@@ -109,6 +109,36 @@ function buildStateMarkdown(
109
109
  return `---\n${frontMatter}\n---\n\n${sections.join('\n')}`;
110
110
  }
111
111
 
112
+ /**
113
+ * Derive epic status from its children's actual statuses.
114
+ * All children done/cancelled → done (auto-complete the epic).
115
+ */
116
+ function deriveEpicDone(epic: Issue, issueByPath: Map<string, Issue>): boolean {
117
+ if (epic.children.length === 0) return false;
118
+ if (epic.status === 'done' || epic.status === 'cancelled') return false;
119
+
120
+ return epic.children.every(childPath => {
121
+ const child = issueByPath.get(childPath);
122
+ return child && (child.status === 'done' || child.status === 'cancelled');
123
+ });
124
+ }
125
+
126
+ function reconcileEpicStatuses(pmDir: string, issues: Issue[], issueByPath: Map<string, Issue>): void {
127
+ const epics = issues.filter(i => i.type === 'epic');
128
+ for (const epic of epics) {
129
+ if (!deriveEpicDone(epic, issueByPath)) continue;
130
+
131
+ const epicPath = join(pmDir, epic.path);
132
+ try {
133
+ let content = readFileSync(epicPath, 'utf-8');
134
+ content = replaceFrontMatterField(content, 'status', 'done');
135
+ writeFileSync(epicPath, content, 'utf-8');
136
+ } catch {
137
+ // Epic file may be missing or unwritable
138
+ }
139
+ }
140
+ }
141
+
112
142
  /**
113
143
  * Derive sprint status from its issues' actual statuses.
114
144
  * - All issues done/cancelled → completed
@@ -155,6 +185,40 @@ function reconcileSprintStatuses(pmDir: string, sprints: Sprint[], issueByPath:
155
185
  }
156
186
  }
157
187
 
188
+ /**
189
+ * After an issue is updated, check if its parent epic should be auto-completed.
190
+ * Returns the epic's relative path if it was marked done, null otherwise.
191
+ */
192
+ export function tryCompleteParentEpic(workingDir: string, updatedIssue: Issue): string | null {
193
+ if (!updatedIssue.epic) return null;
194
+
195
+ const pmDir = resolvePmDir(workingDir);
196
+ if (!pmDir) return null;
197
+
198
+ // Determine which board the issue belongs to from its path
199
+ const boardMatch = updatedIssue.path.match(/^boards\/([^/]+)\//);
200
+ const issues = boardMatch
201
+ ? parseBoardDirectory(pmDir, boardMatch[1])?.issues
202
+ : parsePlanDirectory(workingDir)?.issues;
203
+ if (!issues) return null;
204
+
205
+ const epic = issues.find(i => i.path === updatedIssue.epic);
206
+ if (!epic) return null;
207
+
208
+ const issueByPath = new Map(issues.map(i => [i.path, i]));
209
+ if (!deriveEpicDone(epic, issueByPath)) return null;
210
+
211
+ const epicFullPath = join(pmDir, epic.path);
212
+ try {
213
+ let content = readFileSync(epicFullPath, 'utf-8');
214
+ content = replaceFrontMatterField(content, 'status', 'done');
215
+ writeFileSync(epicFullPath, content, 'utf-8');
216
+ return epic.path;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
158
222
  export function reconcileState(workingDir: string, boardId?: string): void {
159
223
  const pmDir = resolvePmDir(workingDir);
160
224
  if (!pmDir) return;
@@ -183,7 +247,8 @@ export function reconcileState(workingDir: string, boardId?: string): void {
183
247
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
184
248
  const frontMatter = fmMatch ? fmMatch[1] : `project: "${project.name}"\ncurrent_sprint: null\nactive_milestone: null\npaused: false\nlast_session: null`;
185
249
 
186
- // Reconcile sprint statuses from actual issue statuses
250
+ // Reconcile epic and sprint statuses from actual issue statuses
251
+ reconcileEpicStatuses(pmDir, issues, issueByPath);
187
252
  reconcileSprintStatuses(pmDir, sprints, issueByPath);
188
253
 
189
254
  // Update current_sprint in front matter based on actual sprint statuses
@@ -211,6 +276,10 @@ function reconcileBoardState(pmDir: string, _workingDir: string, boardId?: strin
211
276
  const { board, issues } = boardState;
212
277
 
213
278
  const issueByPath = new Map(issues.map(i => [i.path, i]));
279
+
280
+ // Reconcile epic statuses before categorizing
281
+ reconcileEpicStatuses(pmDir, issues, issueByPath);
282
+
214
283
  const categories = categorizeIssues(issues, issueByPath);
215
284
  const warnings = computeWarnings(issues);
216
285
 
@@ -21,7 +21,6 @@ import {
21
21
  updateCredentials,
22
22
  } from './platform-credentials.js'
23
23
  import { captureException } from './sentry.js'
24
- import { isBwrapAvailable } from './terminal/pty-utils.js'
25
24
 
26
25
  /**
27
26
  * Get machine identification string
@@ -40,8 +39,12 @@ let WebSocketImpl: typeof WebSocket
40
39
  if (typeof WebSocket !== 'undefined') {
41
40
  WebSocketImpl = WebSocket
42
41
  } else {
43
- const { default: WS } = await import('ws')
44
- WebSocketImpl = WS as unknown as typeof WebSocket
42
+ try {
43
+ const { default: WS } = await import('ws')
44
+ WebSocketImpl = WS as unknown as typeof WebSocket
45
+ } catch {
46
+ throw new Error('WebSocket not available: install the "ws" package or use Node.js 21+')
47
+ }
45
48
  }
46
49
 
47
50
  // PLATFORM_URL is set via --server / --dev flag in mstro.js
@@ -122,7 +125,7 @@ export class PlatformConnection {
122
125
 
123
126
  private startHeartbeat(): void {
124
127
  this.missedPongs = 0
125
- this.heartbeatInterval = setInterval(() => this.heartbeatTick(), 2 * 60 * 1000)
128
+ this.heartbeatInterval = setInterval(() => this.heartbeatTick(), 25_000)
126
129
  }
127
130
 
128
131
  private heartbeatTick(): void {
@@ -186,7 +189,7 @@ export class PlatformConnection {
186
189
  osType,
187
190
  cpuArch,
188
191
  cliVersion: CLI_VERSION,
189
- capabilities: JSON.stringify({ terminalSandbox: isBwrapAvailable() }),
192
+ capabilities: JSON.stringify({}),
190
193
  startedAt: this.startedAt,
191
194
  })
192
195
 
@@ -231,6 +234,7 @@ export class PlatformConnection {
231
234
  }
232
235
 
233
236
  this.ws.onclose = (event) => {
237
+ clearTimeout(connectionTimeout)
234
238
  this.stopHeartbeat()
235
239
  this.isConnected = false
236
240
 
@@ -254,6 +258,7 @@ export class PlatformConnection {
254
258
  }
255
259
 
256
260
  this.ws.onerror = () => {
261
+ clearTimeout(connectionTimeout)
257
262
  // onclose will be called after this
258
263
  }
259
264
  }
@@ -275,6 +280,10 @@ export class PlatformConnection {
275
280
  this.callbacks.onWebDisconnected?.()
276
281
  trackEvent(AnalyticsEvents.WEB_CLIENT_DISCONNECTED)
277
282
  break
283
+ case 'ping':
284
+ // Server-initiated ping — respond with pong to reset stale detection
285
+ this.send({ type: 'pong' })
286
+ break
278
287
  case 'pong':
279
288
  this.missedPongs = 0
280
289
  break
@@ -293,7 +302,9 @@ export class PlatformConnection {
293
302
  }
294
303
 
295
304
  this.reconnectAttempts++
296
- const delay = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 30000)
305
+ const base = Math.min(1000 * 2 ** (this.reconnectAttempts - 1), 30000)
306
+ const jitter = base * 0.25 * (2 * Math.random() - 1)
307
+ const delay = Math.max(0, Math.round(base + jitter))
297
308
 
298
309
  this.reconnectTimeout = setTimeout(() => {
299
310
  this.reconnectTimeout = null
@@ -10,17 +10,14 @@
10
10
 
11
11
  import { EventEmitter } from 'node:events';
12
12
  import { homedir, platform } from 'node:os';
13
- import { sanitizeEnvForSandbox } from '../sandbox-utils.js';
14
13
  import type { PTYSession } from './pty-utils.js';
15
14
  import {
16
- buildBwrapArgs,
17
15
  detectShell,
18
16
  getPty,
19
17
  getPtyInstallInstructions,
20
18
  getShellName,
21
- isBwrapAvailable,
22
19
  isPtyAvailable,
23
- SCROLLBACK_MAX_BYTES,
20
+ SCROLLBACK_MAX_LENGTH,
24
21
  ScrollbackBuffer,
25
22
  } from './pty-utils.js';
26
23
 
@@ -54,14 +51,13 @@ export class PTYManager extends EventEmitter {
54
51
  return getPtyInstallInstructions();
55
52
  }
56
53
 
57
- create(
54
+ async create(
58
55
  terminalId: string,
59
56
  workingDir: string,
60
57
  cols: number = 80,
61
58
  rows: number = 24,
62
59
  requestedShell?: string,
63
- options?: { sandboxed?: boolean }
64
- ): { shell: string; cwd: string; isReconnect: boolean; platform: string } {
60
+ ): Promise<{ shell: string; cwd: string; isReconnect: boolean; platform: string }> {
65
61
  const pty = getPty();
66
62
  if (!pty) {
67
63
  throw new Error(`PTY_NOT_AVAILABLE:${getPtyInstallInstructions()}`);
@@ -80,32 +76,9 @@ export class PTYManager extends EventEmitter {
80
76
  const cwd = workingDir || homedir();
81
77
 
82
78
  try {
83
- const baseEnv = options?.sandboxed
84
- ? sanitizeEnvForSandbox(process.env, cwd)
85
- : { ...process.env, HOME: homedir() };
86
- const env = { ...baseEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor' };
79
+ const env = { ...process.env, HOME: homedir(), TERM: 'xterm-256color', COLORTERM: 'truecolor' };
87
80
 
88
- // Sandboxed terminals use bubblewrap (bwrap) for filesystem isolation.
89
- // The shell is spawned inside a namespace that only sees the project directory (rw)
90
- // and system directories (ro). Without bwrap, sandboxed terminals are not available.
91
- let spawnCommand: string;
92
- let spawnArgs: string[];
93
- let spawnCwd: string;
94
-
95
- if (options?.sandboxed) {
96
- if (!isBwrapAvailable()) {
97
- throw new Error('SANDBOX_UNAVAILABLE:Terminal sandbox (bubblewrap) is not installed on this machine. Shared terminal sessions require bubblewrap for filesystem isolation.');
98
- }
99
- spawnCommand = '/usr/bin/bwrap';
100
- spawnArgs = buildBwrapArgs(cwd, shell);
101
- spawnCwd = '/'; // bwrap manages cwd internally via --chdir
102
- } else {
103
- spawnCommand = shell;
104
- spawnArgs = [];
105
- spawnCwd = cwd;
106
- }
107
-
108
- const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { name: 'xterm-256color', cols, rows, cwd: spawnCwd, env });
81
+ const ptyProcess = pty.spawn(shell, [], { name: 'xterm-256color', cols, rows, cwd, env });
109
82
 
110
83
  const session: PTYSession = {
111
84
  id: terminalId,
@@ -118,7 +91,7 @@ export class PTYManager extends EventEmitter {
118
91
  rows,
119
92
  _outputBuffer: '',
120
93
  _outputTimer: null,
121
- scrollback: new ScrollbackBuffer(SCROLLBACK_MAX_BYTES),
94
+ scrollback: new ScrollbackBuffer(SCROLLBACK_MAX_LENGTH),
122
95
  };
123
96
  this.terminals.set(terminalId, session);
124
97
 
@@ -8,8 +8,6 @@
8
8
  * on session lifecycle orchestration.
9
9
  */
10
10
 
11
- import { execSync } from 'node:child_process';
12
- import { accessSync, constants as fsConstants, lstatSync } from 'node:fs';
13
11
  import { createRequire } from 'node:module';
14
12
  import { platform } from 'node:os';
15
13
 
@@ -117,89 +115,13 @@ export function getShellName(shellPath: string): string {
117
115
  return parts[parts.length - 1] || 'shell';
118
116
  }
119
117
 
120
- // ── Bubblewrap (bwrap) sandbox detection ─────────────────────
121
-
122
- let _bwrapAvailable: boolean | null = null;
123
-
124
- /**
125
- * Check if bubblewrap (bwrap) is available for filesystem sandboxing.
126
- * Required for sandboxed terminal sessions (shared "can control" users).
127
- * Caches the result after first check.
128
- */
129
- export function isBwrapAvailable(): boolean {
130
- if (_bwrapAvailable !== null) return _bwrapAvailable;
131
-
132
- if (platform() !== 'linux') {
133
- _bwrapAvailable = false;
134
- return false;
135
- }
136
-
137
- try {
138
- accessSync('/usr/bin/bwrap', fsConstants.X_OK);
139
- execSync('bwrap --ro-bind / / -- /bin/true', { timeout: 5000, stdio: 'ignore' });
140
- _bwrapAvailable = true;
141
- } catch {
142
- _bwrapAvailable = false;
143
- }
144
- return _bwrapAvailable;
145
- }
146
-
147
- /**
148
- * Build bwrap arguments to sandbox a shell to a specific directory.
149
- * Provides read-only access to system directories, read-write to the project dir only.
150
- */
151
- export function buildBwrapArgs(cwd: string, shell: string): string[] {
152
- const mergedUsr = (() => {
153
- try { return lstatSync('/bin').isSymbolicLink(); }
154
- catch { return false; }
155
- })();
156
-
157
- const args: string[] = [
158
- '--ro-bind', '/usr', '/usr',
159
- '--ro-bind', '/etc', '/etc',
160
- // Hide sensitive /etc files by binding /dev/null over them
161
- '--ro-bind', '/dev/null', '/etc/shadow',
162
- '--ro-bind', '/dev/null', '/etc/gshadow',
163
- ];
164
-
165
- if (mergedUsr) {
166
- // Merged-usr distros (Fedora, Ubuntu 20.04+, Arch, Debian 12+)
167
- args.push('--symlink', 'usr/bin', '/bin');
168
- args.push('--symlink', 'usr/sbin', '/sbin');
169
- args.push('--symlink', 'usr/lib', '/lib');
170
- try { lstatSync('/lib64'); args.push('--symlink', 'usr/lib64', '/lib64'); } catch { /* skip */ }
171
- } else {
172
- args.push('--ro-bind', '/bin', '/bin');
173
- args.push('--ro-bind', '/sbin', '/sbin');
174
- args.push('--ro-bind', '/lib', '/lib');
175
- try { lstatSync('/lib64'); args.push('--ro-bind', '/lib64', '/lib64'); } catch { /* skip */ }
176
- }
177
-
178
- args.push(
179
- '--proc', '/proc',
180
- '--dev', '/dev',
181
- '--tmpfs', '/tmp',
182
- '--tmpfs', '/run',
183
- // Read-write access to the project directory only
184
- '--bind', cwd, cwd,
185
- '--unshare-pid',
186
- '--unshare-ipc',
187
- '--die-with-parent',
188
- '--chdir', cwd,
189
- '--',
190
- shell,
191
- );
192
-
193
- return args;
194
- }
195
-
196
118
  // ── Scrollback buffer ─────────────────────────────────────────
197
119
 
198
- export const SCROLLBACK_MAX_BYTES = 256 * 1024; // 256KB
120
+ export const SCROLLBACK_MAX_LENGTH = 256 * 1024; // ~256K characters
199
121
 
200
122
  /**
201
123
  * Fixed-size buffer that retains the most recent PTY output for replay on reconnect.
202
- * Stores raw string chunks and evicts oldest data when the total exceeds maxBytes.
124
+ * Stores raw string chunks and evicts oldest data when the total exceeds maxLength.
203
125
  */
204
126
  export class ScrollbackBuffer {
205
127
  private chunks: string[] = [];
@@ -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);