- Auto Run docs now support repo paths *and* GitHub attachment links 📎

- Introduced `DocumentReference` objects with name, path, and external flag 🧩
- Smarter issue parsing extracts markdown `.md` links into downloadable docs 🔍
- Dedupes documents by filename, preferring external attachments when present 🧠
- Added 1MB issue-body parsing cap to prevent performance blowups 🚧
- Path traversal checks now apply only to repo-relative document references 🛡️
- Auto Run Docs setup can download external files directly via `fetch` 🌐
- Switched runner file ops to Node `fs`, replacing shell `cp/rm` usage 🧰
- Runner now configures git user.name/email to ensure commits always work 🪪
- Failure paths now clean up local repos automatically to reduce clutter 🧹
- History virtualization now measures elements directly for more accurate sizing 📏
- Marketplace and Symphony modals widened for a roomier workflow view 🖥️
This commit is contained in:
Pedram Amini
2026-01-08 08:33:23 -06:00
parent 7bd1c51c05
commit 5da6247fae
4 changed files with 221 additions and 61 deletions

View File

@@ -43,6 +43,7 @@ import type {
StartContributionResponse, StartContributionResponse,
CompleteContributionResponse, CompleteContributionResponse,
IssueStatus, IssueStatus,
DocumentReference,
} from '../../../shared/symphony-types'; } from '../../../shared/symphony-types';
import { SymphonyError } from '../../../shared/symphony-types'; import { SymphonyError } from '../../../shared/symphony-types';
@@ -126,7 +127,7 @@ function validateContributionParams(params: {
repoUrl: string; repoUrl: string;
repoName: string; repoName: string;
issueNumber: number; issueNumber: number;
documentPaths: string[]; documentPaths: DocumentReference[];
}): { valid: boolean; error?: string } { }): { valid: boolean; error?: string } {
// Validate repo slug // Validate repo slug
const slugValidation = validateRepoSlug(params.repoSlug); const slugValidation = validateRepoSlug(params.repoSlug);
@@ -150,10 +151,12 @@ function validateContributionParams(params: {
return { valid: false, error: 'Invalid issue number' }; return { valid: false, error: 'Invalid issue number' };
} }
// Validate document paths (check for path traversal) // Validate document paths (check for path traversal in repo-relative paths)
for (const docPath of params.documentPaths) { for (const doc of params.documentPaths) {
if (docPath.includes('..') || docPath.startsWith('/')) { // Skip validation for external URLs (they're downloaded, not file paths)
return { valid: false, error: `Invalid document path: ${docPath}` }; if (doc.isExternal) continue;
if (doc.path.includes('..') || doc.path.startsWith('/')) {
return { valid: false, error: `Invalid document path: ${doc.path}` };
} }
} }
@@ -280,25 +283,69 @@ function generateBranchName(issueNumber: number): string {
.replace('{timestamp}', timestamp); .replace('{timestamp}', timestamp);
} }
/** /** Maximum body size to parse (1MB) to prevent performance issues */
* Parse document paths from issue body. const MAX_BODY_SIZE = 1024 * 1024;
*/
function parseDocumentPaths(body: string): string[] {
const paths: Set<string> = new Set();
for (const pattern of DOCUMENT_PATH_PATTERNS) { /**
// Reset lastIndex for global regex * Parse document references from issue body.
pattern.lastIndex = 0; * Supports both repository-relative paths and GitHub attachment links.
let match; */
while ((match = pattern.exec(body)) !== null) { function parseDocumentPaths(body: string): DocumentReference[] {
const docPath = match[1]; // Guard against extremely large bodies that could cause performance issues
if (docPath && !docPath.startsWith('http')) { if (body.length > MAX_BODY_SIZE) {
paths.add(docPath); logger.warn('Issue body too large, truncating for document parsing', LOG_CONTEXT, {
bodyLength: body.length,
maxSize: MAX_BODY_SIZE,
});
body = body.substring(0, MAX_BODY_SIZE);
}
const docs: Map<string, DocumentReference> = new Map();
// Pattern for markdown links: [filename.md](url)
// Captures: [1] = filename (link text), [2] = URL
const markdownLinkPattern = /\[([^\]]+\.md)\]\(([^)]+)\)/gi;
// First, check for markdown links (GitHub attachments)
let match;
while ((match = markdownLinkPattern.exec(body)) !== null) {
const filename = match[1];
const url = match[2];
// Only add if it's a GitHub attachment URL or similar external URL
if (url.startsWith('http')) {
const key = filename.toLowerCase(); // Dedupe by filename
if (!docs.has(key)) {
docs.set(key, {
name: filename,
path: url,
isExternal: true,
});
} }
} }
} }
return Array.from(paths); // Then check for repo-relative paths using existing patterns
for (const pattern of DOCUMENT_PATH_PATTERNS) {
// Reset lastIndex for global regex
pattern.lastIndex = 0;
while ((match = pattern.exec(body)) !== null) {
const docPath = match[1];
if (docPath && !docPath.startsWith('http')) {
const filename = docPath.split('/').pop() || docPath;
const key = filename.toLowerCase();
// Don't overwrite external links with same filename
if (!docs.has(key)) {
docs.set(key, {
name: filename,
path: docPath,
isExternal: false,
});
}
}
}
}
return Array.from(docs.values());
} }
// ============================================================================ // ============================================================================
@@ -751,7 +798,7 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
repoName: string; repoName: string;
issueNumber: number; issueNumber: number;
issueTitle: string; issueTitle: string;
documentPaths: string[]; documentPaths: DocumentReference[];
agentType: string; agentType: string;
sessionId: string; sessionId: string;
baseBranch?: string; baseBranch?: string;
@@ -1151,7 +1198,7 @@ This PR will be updated automatically when the Auto Run completes.`;
issueNumber: number; issueNumber: number;
issueTitle: string; issueTitle: string;
localPath: string; localPath: string;
documentPaths: string[]; documentPaths: DocumentReference[];
}): Promise<{ }): Promise<{
success: boolean; success: boolean;
branchName?: string; branchName?: string;
@@ -1172,10 +1219,10 @@ This PR will be updated automatically when the Auto Run completes.`;
return { success: false, error: 'Invalid issue number' }; return { success: false, error: 'Invalid issue number' };
} }
// Validate document paths for path traversal // Validate document paths for path traversal (only repo-relative paths)
for (const docPath of documentPaths) { for (const doc of documentPaths) {
if (docPath.includes('..') || docPath.startsWith('/')) { if (!doc.isExternal && (doc.path.includes('..') || doc.path.startsWith('/'))) {
return { success: false, error: `Invalid document path: ${docPath}` }; return { success: false, error: `Invalid document path: ${doc.path}` };
} }
} }
@@ -1235,19 +1282,37 @@ This PR will be updated automatically when the Auto Run completes.`;
const autoRunDir = path.join(localPath, 'Auto Run Docs'); const autoRunDir = path.join(localPath, 'Auto Run Docs');
await fs.mkdir(autoRunDir, { recursive: true }); await fs.mkdir(autoRunDir, { recursive: true });
for (const docPath of documentPaths) { for (const doc of documentPaths) {
// Ensure the path doesn't escape the localPath const destPath = path.join(autoRunDir, doc.name);
const resolvedSource = path.resolve(localPath, docPath);
if (!resolvedSource.startsWith(localPath)) { if (doc.isExternal) {
logger.error('Attempted path traversal in document copy', LOG_CONTEXT, { docPath }); // Download external file (GitHub attachment)
continue; try {
} logger.info('Downloading external document', LOG_CONTEXT, { name: doc.name, url: doc.path });
const destPath = path.join(autoRunDir, path.basename(docPath)); const response = await fetch(doc.path);
try { if (!response.ok) {
await fs.copyFile(resolvedSource, destPath); logger.warn('Failed to download document', LOG_CONTEXT, { name: doc.name, status: response.status });
logger.info('Copied document', LOG_CONTEXT, { from: resolvedSource, to: destPath }); continue;
} catch (e) { }
logger.warn('Failed to copy document', LOG_CONTEXT, { docPath, error: e instanceof Error ? e.message : String(e) }); const buffer = await response.arrayBuffer();
await fs.writeFile(destPath, Buffer.from(buffer));
logger.info('Downloaded document', LOG_CONTEXT, { name: doc.name, to: destPath });
} catch (e) {
logger.warn('Failed to download document', LOG_CONTEXT, { name: doc.name, error: e instanceof Error ? e.message : String(e) });
}
} else {
// Copy from repo - ensure the path doesn't escape the localPath
const resolvedSource = path.resolve(localPath, doc.path);
if (!resolvedSource.startsWith(localPath)) {
logger.error('Attempted path traversal in document copy', LOG_CONTEXT, { docPath: doc.path });
continue;
}
try {
await fs.copyFile(resolvedSource, destPath);
logger.info('Copied document', LOG_CONTEXT, { from: resolvedSource, to: destPath });
} catch (e) {
logger.warn('Failed to copy document', LOG_CONTEXT, { docPath: doc.path, error: e instanceof Error ? e.message : String(e) });
}
} }
} }

View File

@@ -5,20 +5,32 @@
*/ */
import path from 'path'; import path from 'path';
import fs from 'fs/promises';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { execFileNoThrow } from '../utils/execFile'; import { execFileNoThrow } from '../utils/execFile';
// Types imported for documentation and future use import type { DocumentReference } from '../../shared/symphony-types';
// import type { ActiveContribution, SymphonyIssue } from '../../shared/symphony-types';
const LOG_CONTEXT = '[SymphonyRunner]'; const LOG_CONTEXT = '[SymphonyRunner]';
/**
* Clean up local repository directory on failure.
*/
async function cleanupLocalRepo(localPath: string): Promise<void> {
try {
await fs.rm(localPath, { recursive: true, force: true });
logger.info('Cleaned up local repository', LOG_CONTEXT, { localPath });
} catch (error) {
logger.warn('Failed to cleanup local repository', LOG_CONTEXT, { localPath, error });
}
}
export interface SymphonyRunnerOptions { export interface SymphonyRunnerOptions {
contributionId: string; contributionId: string;
repoSlug: string; repoSlug: string;
repoUrl: string; repoUrl: string;
issueNumber: number; issueNumber: number;
issueTitle: string; issueTitle: string;
documentPaths: string[]; documentPaths: DocumentReference[];
localPath: string; localPath: string;
branchName: string; branchName: string;
onProgress?: (progress: { completedDocuments: number; totalDocuments: number }) => void; onProgress?: (progress: { completedDocuments: number; totalDocuments: number }) => void;
@@ -42,6 +54,23 @@ async function createBranch(localPath: string, branchName: string): Promise<bool
return result.exitCode === 0; return result.exitCode === 0;
} }
/**
* Configure git user for commits (required for users without global git config).
*/
async function configureGitUser(localPath: string): Promise<boolean> {
const nameResult = await execFileNoThrow('git', ['config', 'user.name', 'Maestro Symphony'], localPath);
if (nameResult.exitCode !== 0) {
logger.warn('Failed to set git user.name', LOG_CONTEXT, { error: nameResult.stderr });
return false;
}
const emailResult = await execFileNoThrow('git', ['config', 'user.email', 'symphony@runmaestro.ai'], localPath);
if (emailResult.exitCode !== 0) {
logger.warn('Failed to set git user.email', LOG_CONTEXT, { error: emailResult.stderr });
return false;
}
return true;
}
/** /**
* Create an empty commit to enable pushing without changes. * Create an empty commit to enable pushing without changes.
*/ */
@@ -98,19 +127,58 @@ Closes #${issueNumber}
} }
/** /**
* Copy Auto Run documents from repo to local Auto Run Docs folder. * Download a file from a URL.
*/
async function downloadFile(url: string, destPath: string): Promise<boolean> {
try {
const response = await fetch(url);
if (!response.ok) {
logger.error('Failed to download file', LOG_CONTEXT, { url, status: response.status });
return false;
}
const buffer = await response.arrayBuffer();
await fs.writeFile(destPath, Buffer.from(buffer));
return true;
} catch (error) {
logger.error('Error downloading file', LOG_CONTEXT, { url, error });
return false;
}
}
/**
* Copy or download Auto Run documents to local Auto Run Docs folder.
* Handles both repo-relative paths and external URLs (GitHub attachments).
*/ */
async function setupAutoRunDocs( async function setupAutoRunDocs(
localPath: string, localPath: string,
documentPaths: string[] documentPaths: DocumentReference[]
): Promise<string> { ): Promise<string> {
const autoRunPath = path.join(localPath, 'Auto Run Docs'); const autoRunPath = path.join(localPath, 'Auto Run Docs');
await execFileNoThrow('mkdir', ['-p', autoRunPath]); await fs.mkdir(autoRunPath, { recursive: true });
for (const docPath of documentPaths) { for (const doc of documentPaths) {
const sourcePath = path.join(localPath, docPath); const destPath = path.join(autoRunPath, doc.name);
const destPath = path.join(autoRunPath, path.basename(docPath));
await execFileNoThrow('cp', [sourcePath, destPath]); if (doc.isExternal) {
// Download external file (GitHub attachment)
logger.info('Downloading external document', LOG_CONTEXT, { name: doc.name, url: doc.path });
const success = await downloadFile(doc.path, destPath);
if (!success) {
logger.warn('Failed to download document, skipping', LOG_CONTEXT, { name: doc.name });
}
} else {
// Copy from repo using Node.js fs API
const sourcePath = path.join(localPath, doc.path);
try {
await fs.copyFile(sourcePath, destPath);
} catch (error) {
logger.warn('Failed to copy document', LOG_CONTEXT, {
name: doc.name,
source: sourcePath,
error: error instanceof Error ? error.message : String(error)
});
}
}
} }
return autoRunPath; return autoRunPath;
@@ -154,23 +222,30 @@ export async function startContribution(options: SymphonyRunnerOptions): Promise
// 2. Create branch // 2. Create branch
onStatusChange?.('setting_up'); onStatusChange?.('setting_up');
if (!await createBranch(localPath, branchName)) { if (!await createBranch(localPath, branchName)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Branch creation failed' }; return { success: false, error: 'Branch creation failed' };
} }
// 2.5. Configure git user for commits
await configureGitUser(localPath);
// 3. Empty commit // 3. Empty commit
const commitMessage = `[Symphony] Start contribution for #${issueNumber}`; const commitMessage = `[Symphony] Start contribution for #${issueNumber}`;
if (!await createEmptyCommit(localPath, commitMessage)) { if (!await createEmptyCommit(localPath, commitMessage)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Empty commit failed' }; return { success: false, error: 'Empty commit failed' };
} }
// 4. Push branch // 4. Push branch
if (!await pushBranch(localPath, branchName)) { if (!await pushBranch(localPath, branchName)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Push failed' }; return { success: false, error: 'Push failed' };
} }
// 5. Create draft PR // 5. Create draft PR
const prResult = await createDraftPR(localPath, issueNumber, issueTitle); const prResult = await createDraftPR(localPath, issueNumber, issueTitle);
if (!prResult.success) { if (!prResult.success) {
await cleanupLocalRepo(localPath);
return { success: false, error: prResult.error }; return { success: false, error: prResult.error };
} }
@@ -187,6 +262,7 @@ export async function startContribution(options: SymphonyRunnerOptions): Promise
autoRunPath, autoRunPath,
}; };
} catch (error) { } catch (error) {
await cleanupLocalRepo(localPath);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@@ -203,6 +279,9 @@ export async function finalizeContribution(
issueNumber: number, issueNumber: number,
issueTitle: string issueTitle: string
): Promise<{ success: boolean; prUrl?: string; error?: string }> { ): Promise<{ success: boolean; prUrl?: string; error?: string }> {
// Configure git user for commits (in case not already configured)
await configureGitUser(localPath);
// Commit all changes // Commit all changes
await execFileNoThrow('git', ['add', '-A'], localPath); await execFileNoThrow('git', ['add', '-A'], localPath);
@@ -281,9 +360,9 @@ export async function cancelContribution(
logger.warn('Failed to close PR', LOG_CONTEXT, { prNumber, error: closeResult.stderr }); logger.warn('Failed to close PR', LOG_CONTEXT, { prNumber, error: closeResult.stderr });
} }
// Clean up local directory // Clean up local directory using Node.js fs API
if (cleanup) { if (cleanup) {
await execFileNoThrow('rm', ['-rf', localPath]); await cleanupLocalRepo(localPath);
} }
return { success: true }; return { success: true };

View File

@@ -290,8 +290,8 @@ function IssueCard({
{issue.documentPaths.length > 0 && ( {issue.documentPaths.length > 0 && (
<div className="mt-2 text-xs" style={{ color: theme.colors.textDim }}> <div className="mt-2 text-xs" style={{ color: theme.colors.textDim }}>
{issue.documentPaths.slice(0, 2).map((path, i) => ( {issue.documentPaths.slice(0, 2).map((doc, i) => (
<div key={i} className="truncate"> {path}</div> <div key={i} className="truncate"> {doc.name}</div>
))} ))}
{issue.documentPaths.length > 2 && ( {issue.documentPaths.length > 2 && (
<div>...and {issue.documentPaths.length - 2} more</div> <div>...and {issue.documentPaths.length - 2} more</div>
@@ -491,17 +491,17 @@ function RepositoryDetailView({
{/* Document tabs */} {/* Document tabs */}
<div className="flex items-center gap-1 px-4 py-2 border-b overflow-x-auto" style={{ borderColor: theme.colors.border }}> <div className="flex items-center gap-1 px-4 py-2 border-b overflow-x-auto" style={{ borderColor: theme.colors.border }}>
{selectedIssue.documentPaths.map((path) => ( {selectedIssue.documentPaths.map((doc) => (
<button <button
key={path} key={doc.name}
onClick={() => handleSelectDoc(path)} onClick={() => handleSelectDoc(doc.path)}
className="px-2 py-1 rounded text-xs whitespace-nowrap transition-colors" className="px-2 py-1 rounded text-xs whitespace-nowrap transition-colors"
style={{ style={{
backgroundColor: selectedDocPath === path ? theme.colors.accent + '20' : 'transparent', backgroundColor: selectedDocPath === doc.path ? theme.colors.accent + '20' : 'transparent',
color: selectedDocPath === path ? theme.colors.accent : theme.colors.textDim, color: selectedDocPath === doc.path ? theme.colors.accent : theme.colors.textDim,
}} }}
> >
{path.split('/').pop()} {doc.name}
</button> </button>
))} ))}
</div> </div>
@@ -1079,7 +1079,7 @@ export function SymphonyModal({
aria-modal="true" aria-modal="true"
aria-labelledby="symphony-modal-title" aria-labelledby="symphony-modal-title"
tabIndex={-1} tabIndex={-1}
className="w-[1000px] max-w-[95vw] rounded-xl shadow-2xl border overflow-hidden flex flex-col max-h-[85vh] outline-none" className="w-[1200px] max-w-[95vw] rounded-xl shadow-2xl border overflow-hidden flex flex-col max-h-[85vh] outline-none"
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }} style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }}
> >
{/* Detail view for projects */} {/* Detail view for projects */}

View File

@@ -69,6 +69,22 @@ export type SymphonyCategory =
// GitHub Issue Types (Fetched via GitHub API) // GitHub Issue Types (Fetched via GitHub API)
// ============================================================================ // ============================================================================
/**
* Reference to an Auto Run document.
* Supports both repository-relative paths and external URLs (e.g., GitHub attachments).
*/
export interface DocumentReference {
/** Display name (filename without path) */
name: string;
/**
* For repo-relative paths: the path within the repository (e.g., "docs/task.md")
* For external files: the download URL
*/
path: string;
/** Whether this is an external URL that needs to be downloaded */
isExternal: boolean;
}
/** /**
* A GitHub issue with the `runmaestro.ai` label. * A GitHub issue with the `runmaestro.ai` label.
* Represents a contribution opportunity. * Represents a contribution opportunity.
@@ -90,8 +106,8 @@ export interface SymphonyIssue {
createdAt: string; createdAt: string;
/** When issue was last updated */ /** When issue was last updated */
updatedAt: string; updatedAt: string;
/** Parsed Auto Run document paths from issue body */ /** Parsed Auto Run document references from issue body */
documentPaths: string[]; documentPaths: DocumentReference[];
/** Availability status */ /** Availability status */
status: IssueStatus; status: IssueStatus;
/** If in progress, the PR working on it */ /** If in progress, the PR working on it */