mstro-app 0.4.13 → 0.4.16
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/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +5 -1
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/services/file-explorer-ops.d.ts +1 -1
- package/dist/server/services/file-explorer-ops.d.ts.map +1 -1
- package/dist/server/services/file-explorer-ops.js +7 -2
- package/dist/server/services/file-explorer-ops.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +3 -2
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +5 -0
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +32 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/review-gate.d.ts +2 -0
- package/dist/server/services/plan/review-gate.d.ts.map +1 -1
- package/dist/server/services/plan/review-gate.js +25 -3
- package/dist/server/services/plan/review-gate.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +2 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/sandbox-utils.d.ts +3 -1
- package/dist/server/services/sandbox-utils.d.ts.map +1 -1
- package/dist/server/services/sandbox-utils.js +6 -3
- package/dist/server/services/sandbox-utils.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js +2 -1
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-log-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-log-handlers.js +29 -9
- package/dist/server/services/websocket/git-log-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +8 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +5 -3
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +4 -1
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-helpers.js +1 -1
- package/dist/server/services/websocket/plan-helpers.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +67 -14
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +2 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +33 -2
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +33 -0
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +360 -72
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +3 -0
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/package.json +1 -1
- package/server/cli/headless/claude-invoker-process.ts +6 -1
- package/server/services/file-explorer-ops.ts +7 -2
- package/server/services/plan/composer.ts +3 -1
- package/server/services/plan/executor.ts +32 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/review-gate.ts +28 -3
- package/server/services/plan/types.ts +2 -0
- package/server/services/sandbox-utils.ts +7 -3
- package/server/services/websocket/file-explorer-handlers.ts +2 -1
- package/server/services/websocket/git-log-handlers.ts +30 -9
- package/server/services/websocket/git-worktree-handlers.ts +9 -0
- package/server/services/websocket/handler.ts +6 -3
- package/server/services/websocket/plan-execution-handlers.ts +4 -1
- package/server/services/websocket/plan-helpers.ts +1 -1
- package/server/services/websocket/quality-handlers.ts +69 -9
- package/server/services/websocket/quality-persistence.ts +32 -2
- package/server/services/websocket/quality-review-agent.ts +427 -72
- package/server/services/websocket/quality-types.ts +3 -0
|
@@ -108,7 +108,8 @@ export function listDirectory(
|
|
|
108
108
|
export function writeFile(
|
|
109
109
|
filePath: string,
|
|
110
110
|
content: string,
|
|
111
|
-
workingDir: string
|
|
111
|
+
workingDir: string,
|
|
112
|
+
encoding?: 'base64'
|
|
112
113
|
): FileOperationResult {
|
|
113
114
|
if (containsDangerousPatterns(filePath)) {
|
|
114
115
|
console.error(`[FileService] SECURITY: Dangerous pattern in path: "${filePath}"`)
|
|
@@ -133,7 +134,11 @@ export function writeFile(
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
if (encoding === 'base64') {
|
|
138
|
+
writeFileSync(resolvedPath, Buffer.from(content, 'base64'))
|
|
139
|
+
} else {
|
|
140
|
+
writeFileSync(resolvedPath, content, 'utf-8')
|
|
141
|
+
}
|
|
137
142
|
return { success: true, path: resolvedPath.replace(`${workingDir}/`, '') }
|
|
138
143
|
} catch (error: unknown) {
|
|
139
144
|
console.error('[FileService] Error writing file:', error)
|
|
@@ -129,6 +129,7 @@ export async function handlePlanPrompt(
|
|
|
129
129
|
userPrompt: string,
|
|
130
130
|
workingDir: string,
|
|
131
131
|
boardId?: string,
|
|
132
|
+
sandboxed?: boolean,
|
|
132
133
|
): Promise<void> {
|
|
133
134
|
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
134
135
|
const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
|
|
@@ -237,7 +238,7 @@ Implementation guidance.
|
|
|
237
238
|
- Give each child issue clear acceptance criteria and files to modify when possible
|
|
238
239
|
- Set appropriate priorities (P0-P3) based on the issue's importance within the epic
|
|
239
240
|
|
|
240
|
-
User request: ${userPrompt}`;
|
|
241
|
+
User request: ${userPrompt}${sandboxed ? `\n\nIMPORTANT: This session has project-scoped access. You MUST NOT read, write, or access any files outside of "${workingDir}" and its subdirectories. All file operations (Read, Write, Edit, Glob, Grep, Bash) must target paths within this directory. Do not use absolute paths that escape this directory. Do not use "../" to access parent directories.` : ''}`;
|
|
241
242
|
|
|
242
243
|
try {
|
|
243
244
|
ctx.broadcastToAll({
|
|
@@ -248,6 +249,7 @@ User request: ${userPrompt}`;
|
|
|
248
249
|
const runner = new HeadlessRunner({
|
|
249
250
|
workingDir,
|
|
250
251
|
directPrompt: enrichedPrompt,
|
|
252
|
+
sandboxed: sandboxed ?? false,
|
|
251
253
|
outputCallback: (text: string) => {
|
|
252
254
|
ctx.send(ws, {
|
|
253
255
|
type: 'planPromptStreaming',
|
|
@@ -69,6 +69,8 @@ export class PlanExecutor extends EventEmitter {
|
|
|
69
69
|
private configInstaller: ConfigInstaller;
|
|
70
70
|
/** Flag to prevent start() from clearing scope set by startBoard/startEpic */
|
|
71
71
|
private _scopeSetByCall = false;
|
|
72
|
+
/** When true, HeadlessRunner instances run with sanitized env and project-scoped system prompt. */
|
|
73
|
+
private sandboxed = false;
|
|
72
74
|
private metrics: ExecutionMetrics = {
|
|
73
75
|
issuesCompleted: 0,
|
|
74
76
|
issuesAttempted: 0,
|
|
@@ -85,6 +87,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
85
87
|
|
|
86
88
|
getStatus(): ExecutionStatus { return this.status; }
|
|
87
89
|
getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
|
|
90
|
+
setSandboxed(value: boolean): void { this.sandboxed = value; }
|
|
88
91
|
|
|
89
92
|
async startEpic(epicPath: string): Promise<void> {
|
|
90
93
|
this.epicScope = epicPath;
|
|
@@ -240,14 +243,19 @@ export class PlanExecutor extends EventEmitter {
|
|
|
240
243
|
outputPath,
|
|
241
244
|
});
|
|
242
245
|
|
|
246
|
+
const sandboxPrompt = this.sandboxed
|
|
247
|
+
? `\n\nIMPORTANT: This session has project-scoped access. You MUST NOT read, write, or access any files outside of "${this.workingDir}" and its subdirectories. All file operations (Read, Write, Edit, Glob, Grep, Bash) must target paths within this directory. Do not use absolute paths that escape this directory. Do not use "../" to access parent directories.`
|
|
248
|
+
: '';
|
|
249
|
+
|
|
243
250
|
const runner = new HeadlessRunner({
|
|
244
251
|
workingDir: this.workingDir,
|
|
245
|
-
directPrompt: prompt,
|
|
252
|
+
directPrompt: prompt + sandboxPrompt,
|
|
246
253
|
stallWarningMs: ISSUE_STALL_WARNING_MS,
|
|
247
254
|
stallKillMs: ISSUE_STALL_KILL_MS,
|
|
248
255
|
stallHardCapMs: ISSUE_STALL_HARD_CAP_MS,
|
|
249
256
|
stallMaxExtensions: ISSUE_STALL_MAX_EXTENSIONS,
|
|
250
257
|
verbose: process.env.MSTRO_VERBOSE === '1',
|
|
258
|
+
sandboxed: this.sandboxed,
|
|
251
259
|
outputCallback: (text: string) => {
|
|
252
260
|
this.emit('output', { issueId: issue.id, text });
|
|
253
261
|
},
|
|
@@ -363,6 +371,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
363
371
|
outputPath,
|
|
364
372
|
onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
|
|
365
373
|
logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
|
|
374
|
+
reviewCriteria: this.getBoardReviewCriteria(),
|
|
366
375
|
});
|
|
367
376
|
persistReviewResult(reviewDir, issue, result);
|
|
368
377
|
|
|
@@ -407,6 +416,28 @@ export class PlanExecutor extends EventEmitter {
|
|
|
407
416
|
}
|
|
408
417
|
}
|
|
409
418
|
|
|
419
|
+
/** Read the board's custom review criteria, if set. */
|
|
420
|
+
private getBoardReviewCriteria(): string | undefined {
|
|
421
|
+
const pmDir = this.pmDir;
|
|
422
|
+
if (!pmDir) return undefined;
|
|
423
|
+
|
|
424
|
+
const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
|
|
425
|
+
if (!effectiveBoardId) return undefined;
|
|
426
|
+
|
|
427
|
+
const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
|
|
428
|
+
if (!existsSync(boardMdPath)) return undefined;
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
const content = readFileSync(boardMdPath, 'utf-8');
|
|
432
|
+
const match = content.match(/^review_criteria:\s*"(.+)"/m);
|
|
433
|
+
if (!match) return undefined;
|
|
434
|
+
const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
|
|
435
|
+
return raw || undefined;
|
|
436
|
+
} catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
410
441
|
private pickReadyIssues(): Issue[] {
|
|
411
442
|
const pmDir = this.pmDir;
|
|
412
443
|
if (!pmDir) {
|
|
@@ -397,6 +397,7 @@ export function parseBoard(content: string, filePath: string): Board {
|
|
|
397
397
|
goal: String(fm.goal || sections.get('Goal') || ''),
|
|
398
398
|
executionSummary,
|
|
399
399
|
maxParallelAgents: clampParallelAgents(fm.max_parallel_agents),
|
|
400
|
+
reviewCriteria: String(fm.review_criteria || sections.get('Review Criteria') || '').replace(/\\n/g, '\n'),
|
|
400
401
|
path: filePath,
|
|
401
402
|
};
|
|
402
403
|
}
|
|
@@ -31,6 +31,8 @@ export interface ReviewIssueOptions {
|
|
|
31
31
|
onOutput?: (text: string) => void;
|
|
32
32
|
/** Board-scoped log directory for execution logs. Falls back to global ~/.mstro/logs/headless/ */
|
|
33
33
|
logDir?: string;
|
|
34
|
+
/** Custom board-level review criteria — replaces default review instructions when set */
|
|
35
|
+
reviewCriteria?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -38,12 +40,12 @@ export interface ReviewIssueOptions {
|
|
|
38
40
|
* Returns auto-pass on infrastructure failures to avoid blocking execution.
|
|
39
41
|
*/
|
|
40
42
|
export async function reviewIssue(options: ReviewIssueOptions): Promise<ReviewResult> {
|
|
41
|
-
const { workingDir, issue, pmDir, outputPath, onOutput, logDir } = options;
|
|
43
|
+
const { workingDir, issue, pmDir, outputPath, onOutput, logDir, reviewCriteria } = options;
|
|
42
44
|
const isCodeTask = issue.filesToModify.length > 0;
|
|
43
45
|
const issueType: ReviewResult['issueType'] = isCodeTask ? 'code' : 'non-code';
|
|
44
46
|
|
|
45
47
|
try {
|
|
46
|
-
const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask);
|
|
48
|
+
const prompt = buildReviewPrompt(issue, pmDir, outputPath, isCodeTask, reviewCriteria);
|
|
47
49
|
|
|
48
50
|
const runner = new HeadlessRunner({
|
|
49
51
|
workingDir,
|
|
@@ -162,11 +164,34 @@ export function autoPassResult(issueId: string, issueType: ReviewResult['issueTy
|
|
|
162
164
|
|
|
163
165
|
// ── Private helpers ─────────────────────────────────────────
|
|
164
166
|
|
|
165
|
-
function buildReviewPrompt(issue: Issue, pmDir: string, outputPath: string, isCodeTask: boolean): string {
|
|
167
|
+
function buildReviewPrompt(issue: Issue, pmDir: string, outputPath: string, isCodeTask: boolean, reviewCriteria?: string): string {
|
|
166
168
|
const criteria = issue.acceptanceCriteria
|
|
167
169
|
.map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
|
|
168
170
|
.join('\n');
|
|
169
171
|
|
|
172
|
+
// When custom review criteria are set, use a generic review prompt
|
|
173
|
+
// that applies the user's criteria instead of assuming code review.
|
|
174
|
+
if (reviewCriteria) {
|
|
175
|
+
return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
|
|
176
|
+
${isCodeTask ? `\n## Files Modified\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}` : `\n## Output File\n${outputPath}\n\n## Issue Spec\n${join(pmDir, issue.path)}`}
|
|
177
|
+
|
|
178
|
+
## Acceptance Criteria
|
|
179
|
+
${criteria || 'No specific criteria defined.'}
|
|
180
|
+
|
|
181
|
+
## Review Criteria
|
|
182
|
+
${reviewCriteria}
|
|
183
|
+
|
|
184
|
+
## Instructions
|
|
185
|
+
1. ${isCodeTask ? 'Read each modified file listed above' : 'Read the output file and issue spec at the paths above'}
|
|
186
|
+
2. Check if all acceptance criteria are met
|
|
187
|
+
3. Evaluate against the review criteria above
|
|
188
|
+
|
|
189
|
+
Output EXACTLY one JSON object on its own line (no markdown fencing):
|
|
190
|
+
{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}
|
|
191
|
+
|
|
192
|
+
Include checks for: criteria_met, review_criteria.`;
|
|
193
|
+
}
|
|
194
|
+
|
|
170
195
|
if (isCodeTask) {
|
|
171
196
|
return `You are a code reviewer. Review the work done for issue ${issue.id}: ${issue.title}.
|
|
172
197
|
|
|
@@ -121,6 +121,8 @@ export interface Board {
|
|
|
121
121
|
executionSummary: BoardExecutionSummary | null;
|
|
122
122
|
/** Max parallel headless Claude Code instances per execution wave (default: 3) */
|
|
123
123
|
maxParallelAgents: number;
|
|
124
|
+
/** Custom review criteria instructions — replaces default code-review prompt when set */
|
|
125
|
+
reviewCriteria: string;
|
|
124
126
|
path: string;
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -58,7 +58,8 @@ const BLOCKED_KEYS = new Set([
|
|
|
58
58
|
*/
|
|
59
59
|
export function sanitizeEnvForSandbox(
|
|
60
60
|
env: NodeJS.ProcessEnv,
|
|
61
|
-
workingDir: string
|
|
61
|
+
workingDir: string,
|
|
62
|
+
options?: { overrideHome?: boolean }
|
|
62
63
|
): Record<string, string> {
|
|
63
64
|
const result: Record<string, string> = {};
|
|
64
65
|
|
|
@@ -69,8 +70,11 @@ export function sanitizeEnvForSandbox(
|
|
|
69
70
|
result[key] = value;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
// Override HOME to project directory so `cd ~` stays sandboxed
|
|
73
|
-
|
|
73
|
+
// Override HOME to project directory so `cd ~` stays sandboxed (e.g. terminals).
|
|
74
|
+
// Claude Code processes opt out (overrideHome: false) to preserve OAuth auth lookup.
|
|
75
|
+
if (options?.overrideHome !== false) {
|
|
76
|
+
result.HOME = workingDir;
|
|
77
|
+
}
|
|
74
78
|
// Marker so scripts can detect sandboxed execution
|
|
75
79
|
result.MSTRO_SANDBOXED = '1';
|
|
76
80
|
|
|
@@ -132,7 +132,8 @@ function handleListDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketM
|
|
|
132
132
|
function handleWriteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
133
133
|
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
134
134
|
if (msg.data.content === undefined) throw new Error('Content is required');
|
|
135
|
-
const
|
|
135
|
+
const encoding = msg.data.encoding === 'base64' ? 'base64' as const : undefined;
|
|
136
|
+
const result = writeFile(msg.data.filePath, msg.data.content, workingDir, encoding);
|
|
136
137
|
sendFileResult(ctx, ws, 'fileWritten', tabId, result);
|
|
137
138
|
if (result.success) {
|
|
138
139
|
ctx.broadcastToOthers(ws, {
|
|
@@ -119,23 +119,29 @@ export async function handleGitSetDirectory(ctx: HandlerContext, ws: WSContext,
|
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Security: validate path is within working directory
|
|
122
|
+
// Security: validate path is within working directory OR is a valid worktree of the repo
|
|
123
123
|
const resolvedDir = resolve(directory);
|
|
124
124
|
const resolvedWorkingDir = resolve(workingDir);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
const isWithinWorkingDir = resolvedDir.startsWith(`${resolvedWorkingDir}/`) || resolvedDir === resolvedWorkingDir;
|
|
126
|
+
|
|
127
|
+
if (!isWithinWorkingDir) {
|
|
128
|
+
// Check if the directory is a known worktree of this repo
|
|
129
|
+
const isWorktree = await isValidWorktreePath(resolvedDir, workingDir);
|
|
130
|
+
if (!isWorktree) {
|
|
131
|
+
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Access denied: path outside project directory' } });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
const gitPath = join(
|
|
136
|
+
const gitPath = join(resolvedDir, '.git');
|
|
131
137
|
const isValid = existsSync(gitPath);
|
|
132
138
|
|
|
133
139
|
if (isValid) {
|
|
134
|
-
ctx.gitDirectories.set(tabId,
|
|
140
|
+
ctx.gitDirectories.set(tabId, resolvedDir);
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
const response: GitDirectorySetResponse = {
|
|
138
|
-
directory,
|
|
144
|
+
directory: resolvedDir,
|
|
139
145
|
isValid,
|
|
140
146
|
};
|
|
141
147
|
|
|
@@ -143,7 +149,22 @@ export async function handleGitSetDirectory(ctx: HandlerContext, ws: WSContext,
|
|
|
143
149
|
|
|
144
150
|
if (isValid) {
|
|
145
151
|
const { handleGitStatus } = await import('./git-handlers.js');
|
|
146
|
-
handleGitStatus(ctx, ws, tabId,
|
|
147
|
-
handleGitLog(ctx, ws, { type: 'gitLog', data: { limit: 5 } }, tabId,
|
|
152
|
+
handleGitStatus(ctx, ws, tabId, resolvedDir);
|
|
153
|
+
handleGitLog(ctx, ws, { type: 'gitLog', data: { limit: 5 } }, tabId, resolvedDir);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Check if a path is a registered worktree of the repo at workingDir */
|
|
158
|
+
async function isValidWorktreePath(targetPath: string, workingDir: string): Promise<boolean> {
|
|
159
|
+
const result = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
|
|
160
|
+
if (result.exitCode !== 0) return false;
|
|
161
|
+
|
|
162
|
+
const resolvedTarget = resolve(targetPath);
|
|
163
|
+
for (const line of result.stdout.split('\n')) {
|
|
164
|
+
if (line.startsWith('worktree ')) {
|
|
165
|
+
const wtPath = resolve(line.slice(9).trim());
|
|
166
|
+
if (wtPath === resolvedTarget) return true;
|
|
167
|
+
}
|
|
148
168
|
}
|
|
169
|
+
return false;
|
|
149
170
|
}
|
|
@@ -156,6 +156,15 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
156
156
|
|
|
157
157
|
await executeGitCommand(['worktree', 'prune'], workingDir);
|
|
158
158
|
|
|
159
|
+
// Clean up gitDirectories entries for any tabs referencing the removed worktree
|
|
160
|
+
const resolvedWtPath = join(wtPath); // normalize
|
|
161
|
+
for (const [tid, dir] of ctx.gitDirectories) {
|
|
162
|
+
if (dir === resolvedWtPath || dir === wtPath) {
|
|
163
|
+
ctx.gitDirectories.delete(tid);
|
|
164
|
+
ctx.gitBranches.delete(tid);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
|
|
160
169
|
} catch (error: unknown) {
|
|
161
170
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
|
|
@@ -178,14 +178,17 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
178
178
|
const domain = WebSocketImproviseHandler.DISPATCH[msg.type];
|
|
179
179
|
if (!domain) throw new Error(`Unknown message type: ${msg.type}`);
|
|
180
180
|
|
|
181
|
+
// Resolve effective working directory: use worktree path if tab is on a worktree
|
|
182
|
+
const effectiveDir = this.gitDirectories.get(tabId) || workingDir;
|
|
183
|
+
|
|
181
184
|
switch (domain) {
|
|
182
185
|
case 'session': return handleSessionMessage(this, ws, msg, tabId, permission);
|
|
183
186
|
case 'history': return handleHistoryMessage(this, ws, msg, tabId, workingDir);
|
|
184
|
-
case 'file': return handleFileMessage(this, ws, msg, tabId,
|
|
187
|
+
case 'file': return handleFileMessage(this, ws, msg, tabId, effectiveDir, permission);
|
|
185
188
|
case 'terminal': return handleTerminalMessage(this, ws, msg, tabId, workingDir, permission);
|
|
186
|
-
case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId,
|
|
189
|
+
case 'fileExplorer': return handleFileExplorerMessage(this, ws, msg, tabId, effectiveDir, permission);
|
|
187
190
|
case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
|
|
188
|
-
case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir);
|
|
191
|
+
case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
|
|
189
192
|
case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
|
|
190
193
|
case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
|
|
191
194
|
}
|
|
@@ -24,7 +24,8 @@ export function handlePrompt(
|
|
|
24
24
|
ctx.send(ws, { type: 'planError', data: { error: 'Prompt required' } });
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
const sandboxed = permission === 'control';
|
|
28
|
+
handlePlanPrompt(ctx, ws, prompt, workingDir, boardId, sandboxed).catch(error => {
|
|
28
29
|
ctx.send(ws, {
|
|
29
30
|
type: 'planError',
|
|
30
31
|
data: { error: error instanceof Error ? error.message : String(error) },
|
|
@@ -100,6 +101,7 @@ export function handleExecute(
|
|
|
100
101
|
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
101
102
|
|
|
102
103
|
const executor = getExecutor(workingDir);
|
|
104
|
+
executor.setSandboxed(permission === 'control');
|
|
103
105
|
|
|
104
106
|
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
105
107
|
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
@@ -132,6 +134,7 @@ export function handleExecuteEpic(
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
const executor = getExecutor(workingDir);
|
|
137
|
+
executor.setSandboxed(permission === 'control');
|
|
135
138
|
|
|
136
139
|
if (executor.getStatus() === 'executing' || executor.getStatus() === 'starting') {
|
|
137
140
|
ctx.send(ws, { type: 'planError', data: { error: 'Execution already in progress' } });
|
|
@@ -39,7 +39,7 @@ export function formatYamlValue(value: unknown): string {
|
|
|
39
39
|
if (value.length === 0) return '[]';
|
|
40
40
|
return `[${value.map(v => typeof v === 'string' ? v : String(v)).join(', ')}]`;
|
|
41
41
|
}
|
|
42
|
-
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
42
|
+
return `"${String(value).replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function buildIssueMarkdown(
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
14
15
|
import type { HandlerContext } from './handler-context.js';
|
|
15
16
|
import type { FindingForFix } from './quality-fix-agent.js';
|
|
16
17
|
import { handleFixIssues } from './quality-fix-agent.js';
|
|
@@ -39,6 +40,26 @@ function resolvePath(workingDir: string, dirPath?: string): string {
|
|
|
39
40
|
return join(workingDir, dirPath);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve and validate a directory path for sandboxed users.
|
|
45
|
+
* Returns null if the path escapes the working directory.
|
|
46
|
+
*/
|
|
47
|
+
function resolveAndValidatePath(
|
|
48
|
+
workingDir: string,
|
|
49
|
+
dirPath: string | undefined,
|
|
50
|
+
isSandboxed: boolean,
|
|
51
|
+
): { resolved: string; error?: string } {
|
|
52
|
+
const resolved = resolvePath(workingDir, dirPath);
|
|
53
|
+
if (isSandboxed) {
|
|
54
|
+
const validation = validatePathWithinWorkingDir(resolved, workingDir);
|
|
55
|
+
if (!validation.valid) {
|
|
56
|
+
return { resolved: '', error: validation.error || 'Path outside project directory' };
|
|
57
|
+
}
|
|
58
|
+
return { resolved: validation.resolvedPath };
|
|
59
|
+
}
|
|
60
|
+
return { resolved };
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
// ── Message router ────────────────────────────────────────────
|
|
43
64
|
|
|
44
65
|
export function handleQualityMessage(
|
|
@@ -47,25 +68,33 @@ export function handleQualityMessage(
|
|
|
47
68
|
msg: WebSocketMessage,
|
|
48
69
|
_tabId: string,
|
|
49
70
|
workingDir: string,
|
|
71
|
+
permission?: 'control' | 'view',
|
|
50
72
|
): void {
|
|
73
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
74
|
+
const sendPathError = (path: string, error: string) => {
|
|
75
|
+
ctx.send(ws, { type: 'qualityError', data: { path, error } });
|
|
76
|
+
};
|
|
77
|
+
|
|
51
78
|
const handlers: Record<string, () => void> = {
|
|
52
|
-
qualityDetectTools: () => handleDetectTools(ctx, ws, msg, workingDir),
|
|
53
|
-
qualityScan: () => handleScan(ctx, ws, msg, workingDir),
|
|
54
|
-
qualityInstallTools: () => handleInstallTools(ctx, ws, msg, workingDir),
|
|
79
|
+
qualityDetectTools: () => handleDetectTools(ctx, ws, msg, workingDir, isSandboxed),
|
|
80
|
+
qualityScan: () => handleScan(ctx, ws, msg, workingDir, isSandboxed),
|
|
81
|
+
qualityInstallTools: () => handleInstallTools(ctx, ws, msg, workingDir, isSandboxed),
|
|
55
82
|
qualityCodeReview: () => {
|
|
56
|
-
const dirPath =
|
|
83
|
+
const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
84
|
+
if (error) { sendPathError(msg.data?.path || '.', error); return; }
|
|
57
85
|
const reportPath = msg.data?.path || '.';
|
|
58
86
|
handleCodeReview(ctx, ws, reportPath, dirPath, workingDir, activeReviews, getPersistence);
|
|
59
87
|
},
|
|
60
88
|
qualityFixIssues: () => {
|
|
61
|
-
const dirPath =
|
|
89
|
+
const { resolved: dirPath, error } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
90
|
+
if (error) { sendPathError(msg.data?.path || '.', error); return; }
|
|
62
91
|
const reportPath = msg.data?.path || '.';
|
|
63
92
|
const section: string | undefined = msg.data?.section;
|
|
64
93
|
const findings: FindingForFix[] = msg.data?.findings || [];
|
|
65
94
|
handleFixIssues(ctx, ws, reportPath, dirPath, workingDir, section, findings, getPersistence);
|
|
66
95
|
},
|
|
67
96
|
qualityLoadState: () => handleLoadState(ctx, ws, workingDir),
|
|
68
|
-
qualitySaveDirectories: () => handleSaveDirectories(ctx, ws, msg, workingDir),
|
|
97
|
+
qualitySaveDirectories: () => handleSaveDirectories(ctx, ws, msg, workingDir, isSandboxed),
|
|
69
98
|
};
|
|
70
99
|
|
|
71
100
|
const handler = handlers[msg.type];
|
|
@@ -106,10 +135,26 @@ async function handleSaveDirectories(
|
|
|
106
135
|
ws: WSContext,
|
|
107
136
|
msg: WebSocketMessage,
|
|
108
137
|
workingDir: string,
|
|
138
|
+
isSandboxed = false,
|
|
109
139
|
): Promise<void> {
|
|
110
140
|
try {
|
|
111
141
|
const persistence = getPersistence(workingDir);
|
|
112
142
|
const directories: Array<{ path: string; label: string }> = msg.data?.directories || [];
|
|
143
|
+
|
|
144
|
+
// Validate all directory paths when sandboxed
|
|
145
|
+
if (isSandboxed) {
|
|
146
|
+
for (const dir of directories) {
|
|
147
|
+
const { error } = resolveAndValidatePath(workingDir, dir.path, true);
|
|
148
|
+
if (error) {
|
|
149
|
+
ctx.send(ws, {
|
|
150
|
+
type: 'qualityError',
|
|
151
|
+
data: { path: dir.path, error: `Cannot save directory: ${error}` },
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
113
158
|
persistence.saveConfig(directories);
|
|
114
159
|
} catch (error) {
|
|
115
160
|
ctx.send(ws, {
|
|
@@ -124,8 +169,13 @@ async function handleDetectTools(
|
|
|
124
169
|
ws: WSContext,
|
|
125
170
|
msg: WebSocketMessage,
|
|
126
171
|
workingDir: string,
|
|
172
|
+
isSandboxed = false,
|
|
127
173
|
): Promise<void> {
|
|
128
|
-
const dirPath =
|
|
174
|
+
const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
175
|
+
if (pathError) {
|
|
176
|
+
ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
129
179
|
try {
|
|
130
180
|
const { tools, ecosystem } = await detectTools(dirPath);
|
|
131
181
|
ctx.send(ws, {
|
|
@@ -145,8 +195,13 @@ async function handleScan(
|
|
|
145
195
|
ws: WSContext,
|
|
146
196
|
msg: WebSocketMessage,
|
|
147
197
|
workingDir: string,
|
|
198
|
+
isSandboxed = false,
|
|
148
199
|
): Promise<void> {
|
|
149
|
-
const dirPath =
|
|
200
|
+
const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
201
|
+
if (pathError) {
|
|
202
|
+
ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
150
205
|
const reportPath = msg.data?.path || '.';
|
|
151
206
|
|
|
152
207
|
try {
|
|
@@ -184,8 +239,13 @@ async function handleInstallTools(
|
|
|
184
239
|
ws: WSContext,
|
|
185
240
|
msg: WebSocketMessage,
|
|
186
241
|
workingDir: string,
|
|
242
|
+
isSandboxed = false,
|
|
187
243
|
): Promise<void> {
|
|
188
|
-
const dirPath =
|
|
244
|
+
const { resolved: dirPath, error: pathError } = resolveAndValidatePath(workingDir, msg.data?.path, isSandboxed);
|
|
245
|
+
if (pathError) {
|
|
246
|
+
ctx.send(ws, { type: 'qualityError', data: { path: msg.data?.path || '.', error: pathError } });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
189
249
|
const reportPath = msg.data?.path || '.';
|
|
190
250
|
const toolNames: string[] | undefined = msg.data?.tools;
|
|
191
251
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* .mstro/quality/history.json — Score history entries for trend tracking
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import type { QualityResults } from './quality-service.js';
|
|
17
17
|
|
|
@@ -56,6 +56,7 @@ export interface QualityPersistedState {
|
|
|
56
56
|
// ============================================================================
|
|
57
57
|
|
|
58
58
|
const MAX_HISTORY_ENTRIES = 100;
|
|
59
|
+
const MAX_REPORT_HISTORY_FILES = 200;
|
|
59
60
|
|
|
60
61
|
function slugify(dirPath: string): string {
|
|
61
62
|
if (dirPath === '.' || dirPath === './') return '_root';
|
|
@@ -94,15 +95,18 @@ function writeJson(filePath: string, data: unknown): void {
|
|
|
94
95
|
export class QualityPersistence {
|
|
95
96
|
private qualityDir: string;
|
|
96
97
|
private reportsDir: string;
|
|
98
|
+
private reportHistoryDir: string;
|
|
97
99
|
private configPath: string;
|
|
98
100
|
private historyPath: string;
|
|
99
101
|
|
|
100
102
|
constructor(workingDir: string) {
|
|
101
103
|
this.qualityDir = join(workingDir, '.mstro', 'quality');
|
|
102
104
|
this.reportsDir = join(this.qualityDir, 'reports');
|
|
105
|
+
this.reportHistoryDir = join(this.reportsDir, 'history');
|
|
103
106
|
this.configPath = join(this.qualityDir, 'config.json');
|
|
104
107
|
this.historyPath = join(this.qualityDir, 'history.json');
|
|
105
108
|
ensureDir(this.reportsDir);
|
|
109
|
+
ensureDir(this.reportHistoryDir);
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
// ---- Config (directory list) ----
|
|
@@ -141,6 +145,12 @@ export class QualityPersistence {
|
|
|
141
145
|
const slug = slugify(dirPath);
|
|
142
146
|
const reportPath = join(this.reportsDir, `${slug}.json`);
|
|
143
147
|
writeJson(reportPath, results);
|
|
148
|
+
|
|
149
|
+
// Archive timestamped copy for historical tracking
|
|
150
|
+
const ts = Date.now();
|
|
151
|
+
const archivePath = join(this.reportHistoryDir, `${ts}_${slug}.json`);
|
|
152
|
+
writeJson(archivePath, results);
|
|
153
|
+
this.pruneReportHistory();
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
loadAllReports(directories: QualityDirectoryConfig[]): Record<string, QualityResults> {
|
|
@@ -154,6 +164,19 @@ export class QualityPersistence {
|
|
|
154
164
|
return reports;
|
|
155
165
|
}
|
|
156
166
|
|
|
167
|
+
// ---- Report history pruning ----
|
|
168
|
+
|
|
169
|
+
private pruneReportHistory(): void {
|
|
170
|
+
try {
|
|
171
|
+
const files = readdirSync(this.reportHistoryDir).filter((f) => f.endsWith('.json')).sort();
|
|
172
|
+
if (files.length <= MAX_REPORT_HISTORY_FILES) return;
|
|
173
|
+
const toRemove = files.slice(0, files.length - MAX_REPORT_HISTORY_FILES);
|
|
174
|
+
for (const file of toRemove) {
|
|
175
|
+
try { unlinkSync(join(this.reportHistoryDir, file)); } catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
} catch { /* directory may not exist yet */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
// ---- History (trend tracking) ----
|
|
158
181
|
|
|
159
182
|
loadHistory(): QualityHistoryEntry[] {
|
|
@@ -219,7 +242,14 @@ export class QualityPersistence {
|
|
|
219
242
|
saveCodeReview(dirPath: string, findings: Record<string, unknown>[], summary: string): void {
|
|
220
243
|
const slug = slugify(dirPath);
|
|
221
244
|
const reviewPath = join(this.reportsDir, `${slug}-review.json`);
|
|
222
|
-
|
|
245
|
+
const data = { findings, summary, timestamp: new Date().toISOString() };
|
|
246
|
+
writeJson(reviewPath, data);
|
|
247
|
+
|
|
248
|
+
// Archive timestamped copy for historical tracking
|
|
249
|
+
const ts = Date.now();
|
|
250
|
+
const archivePath = join(this.reportHistoryDir, `${ts}_${slug}-review.json`);
|
|
251
|
+
writeJson(archivePath, data);
|
|
252
|
+
this.pruneReportHistory();
|
|
223
253
|
}
|
|
224
254
|
|
|
225
255
|
// ---- Full state load ----
|