- 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,
CompleteContributionResponse,
IssueStatus,
DocumentReference,
} from '../../../shared/symphony-types';
import { SymphonyError } from '../../../shared/symphony-types';
@@ -126,7 +127,7 @@ function validateContributionParams(params: {
repoUrl: string;
repoName: string;
issueNumber: number;
documentPaths: string[];
documentPaths: DocumentReference[];
}): { valid: boolean; error?: string } {
// Validate repo slug
const slugValidation = validateRepoSlug(params.repoSlug);
@@ -150,10 +151,12 @@ function validateContributionParams(params: {
return { valid: false, error: 'Invalid issue number' };
}
// Validate document paths (check for path traversal)
for (const docPath of params.documentPaths) {
if (docPath.includes('..') || docPath.startsWith('/')) {
return { valid: false, error: `Invalid document path: ${docPath}` };
// Validate document paths (check for path traversal in repo-relative paths)
for (const doc of params.documentPaths) {
// Skip validation for external URLs (they're downloaded, not file paths)
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);
}
/**
* Parse document paths from issue body.
*/
function parseDocumentPaths(body: string): string[] {
const paths: Set<string> = new Set();
/** Maximum body size to parse (1MB) to prevent performance issues */
const MAX_BODY_SIZE = 1024 * 1024;
for (const pattern of DOCUMENT_PATH_PATTERNS) {
// Reset lastIndex for global regex
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(body)) !== null) {
const docPath = match[1];
if (docPath && !docPath.startsWith('http')) {
paths.add(docPath);
/**
* Parse document references from issue body.
* Supports both repository-relative paths and GitHub attachment links.
*/
function parseDocumentPaths(body: string): DocumentReference[] {
// Guard against extremely large bodies that could cause performance issues
if (body.length > MAX_BODY_SIZE) {
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;
issueNumber: number;
issueTitle: string;
documentPaths: string[];
documentPaths: DocumentReference[];
agentType: string;
sessionId: string;
baseBranch?: string;
@@ -1151,7 +1198,7 @@ This PR will be updated automatically when the Auto Run completes.`;
issueNumber: number;
issueTitle: string;
localPath: string;
documentPaths: string[];
documentPaths: DocumentReference[];
}): Promise<{
success: boolean;
branchName?: string;
@@ -1172,10 +1219,10 @@ This PR will be updated automatically when the Auto Run completes.`;
return { success: false, error: 'Invalid issue number' };
}
// Validate document paths for path traversal
for (const docPath of documentPaths) {
if (docPath.includes('..') || docPath.startsWith('/')) {
return { success: false, error: `Invalid document path: ${docPath}` };
// Validate document paths for path traversal (only repo-relative paths)
for (const doc of documentPaths) {
if (!doc.isExternal && (doc.path.includes('..') || doc.path.startsWith('/'))) {
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');
await fs.mkdir(autoRunDir, { recursive: true });
for (const docPath of documentPaths) {
// Ensure the path doesn't escape the localPath
const resolvedSource = path.resolve(localPath, docPath);
if (!resolvedSource.startsWith(localPath)) {
logger.error('Attempted path traversal in document copy', LOG_CONTEXT, { docPath });
continue;
}
const destPath = path.join(autoRunDir, path.basename(docPath));
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, error: e instanceof Error ? e.message : String(e) });
for (const doc of documentPaths) {
const destPath = path.join(autoRunDir, doc.name);
if (doc.isExternal) {
// Download external file (GitHub attachment)
try {
logger.info('Downloading external document', LOG_CONTEXT, { name: doc.name, url: doc.path });
const response = await fetch(doc.path);
if (!response.ok) {
logger.warn('Failed to download document', LOG_CONTEXT, { name: doc.name, status: response.status });
continue;
}
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 fs from 'fs/promises';
import { logger } from '../utils/logger';
import { execFileNoThrow } from '../utils/execFile';
// Types imported for documentation and future use
// import type { ActiveContribution, SymphonyIssue } from '../../shared/symphony-types';
import type { DocumentReference } from '../../shared/symphony-types';
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 {
contributionId: string;
repoSlug: string;
repoUrl: string;
issueNumber: number;
issueTitle: string;
documentPaths: string[];
documentPaths: DocumentReference[];
localPath: string;
branchName: string;
onProgress?: (progress: { completedDocuments: number; totalDocuments: number }) => void;
@@ -42,6 +54,23 @@ async function createBranch(localPath: string, branchName: string): Promise<bool
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.
*/
@@ -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(
localPath: string,
documentPaths: string[]
documentPaths: DocumentReference[]
): Promise<string> {
const autoRunPath = path.join(localPath, 'Auto Run Docs');
await execFileNoThrow('mkdir', ['-p', autoRunPath]);
await fs.mkdir(autoRunPath, { recursive: true });
for (const docPath of documentPaths) {
const sourcePath = path.join(localPath, docPath);
const destPath = path.join(autoRunPath, path.basename(docPath));
await execFileNoThrow('cp', [sourcePath, destPath]);
for (const doc of documentPaths) {
const destPath = path.join(autoRunPath, doc.name);
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;
@@ -154,23 +222,30 @@ export async function startContribution(options: SymphonyRunnerOptions): Promise
// 2. Create branch
onStatusChange?.('setting_up');
if (!await createBranch(localPath, branchName)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Branch creation failed' };
}
// 2.5. Configure git user for commits
await configureGitUser(localPath);
// 3. Empty commit
const commitMessage = `[Symphony] Start contribution for #${issueNumber}`;
if (!await createEmptyCommit(localPath, commitMessage)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Empty commit failed' };
}
// 4. Push branch
if (!await pushBranch(localPath, branchName)) {
await cleanupLocalRepo(localPath);
return { success: false, error: 'Push failed' };
}
// 5. Create draft PR
const prResult = await createDraftPR(localPath, issueNumber, issueTitle);
if (!prResult.success) {
await cleanupLocalRepo(localPath);
return { success: false, error: prResult.error };
}
@@ -187,6 +262,7 @@ export async function startContribution(options: SymphonyRunnerOptions): Promise
autoRunPath,
};
} catch (error) {
await cleanupLocalRepo(localPath);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
@@ -203,6 +279,9 @@ export async function finalizeContribution(
issueNumber: number,
issueTitle: string
): Promise<{ success: boolean; prUrl?: string; error?: string }> {
// Configure git user for commits (in case not already configured)
await configureGitUser(localPath);
// Commit all changes
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 });
}
// Clean up local directory
// Clean up local directory using Node.js fs API
if (cleanup) {
await execFileNoThrow('rm', ['-rf', localPath]);
await cleanupLocalRepo(localPath);
}
return { success: true };

View File

@@ -290,8 +290,8 @@ function IssueCard({
{issue.documentPaths.length > 0 && (
<div className="mt-2 text-xs" style={{ color: theme.colors.textDim }}>
{issue.documentPaths.slice(0, 2).map((path, i) => (
<div key={i} className="truncate"> {path}</div>
{issue.documentPaths.slice(0, 2).map((doc, i) => (
<div key={i} className="truncate"> {doc.name}</div>
))}
{issue.documentPaths.length > 2 && (
<div>...and {issue.documentPaths.length - 2} more</div>
@@ -491,17 +491,17 @@ function RepositoryDetailView({
{/* Document tabs */}
<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
key={path}
onClick={() => handleSelectDoc(path)}
key={doc.name}
onClick={() => handleSelectDoc(doc.path)}
className="px-2 py-1 rounded text-xs whitespace-nowrap transition-colors"
style={{
backgroundColor: selectedDocPath === path ? theme.colors.accent + '20' : 'transparent',
color: selectedDocPath === path ? theme.colors.accent : theme.colors.textDim,
backgroundColor: selectedDocPath === doc.path ? theme.colors.accent + '20' : 'transparent',
color: selectedDocPath === doc.path ? theme.colors.accent : theme.colors.textDim,
}}
>
{path.split('/').pop()}
{doc.name}
</button>
))}
</div>
@@ -1079,7 +1079,7 @@ export function SymphonyModal({
aria-modal="true"
aria-labelledby="symphony-modal-title"
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 }}
>
{/* Detail view for projects */}

View File

@@ -69,6 +69,22 @@ export type SymphonyCategory =
// 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.
* Represents a contribution opportunity.
@@ -90,8 +106,8 @@ export interface SymphonyIssue {
createdAt: string;
/** When issue was last updated */
updatedAt: string;
/** Parsed Auto Run document paths from issue body */
documentPaths: string[];
/** Parsed Auto Run document references from issue body */
documentPaths: DocumentReference[];
/** Availability status */
status: IssueStatus;
/** If in progress, the PR working on it */