From a568b77f6be4f8abfb354f566808a1cc197b55fa Mon Sep 17 00:00:00 2001 From: Automaker Date: Sun, 7 Jun 2026 00:20:17 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20[github]=20bug:=20project=20path=20?= =?UTF-8?q?=E2=86=92=20git=20remote=20misalignment=20corrupts=20cross-repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/services/auto-mode-service.ts | 19 ++ .../src/services/project-health-service.ts | 56 +++++ .../src/services/repo-remote-validation.ts | 180 ++++++++++++++++ .../services/repo-remote-validation.test.ts | 197 ++++++++++++++++++ libs/types/src/project-settings.ts | 12 ++ 5 files changed, 464 insertions(+) create mode 100644 apps/server/src/services/repo-remote-validation.ts create mode 100644 apps/server/tests/unit/services/repo-remote-validation.test.ts diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 3a0337c77..1d97ab74f 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -141,6 +141,7 @@ import { import { runInitScript } from './init-script-service.js'; import { checkFeatureRestartOutcome } from './startup-recovery-service.js'; import { checkAppCompliance, buildComplianceRefusalMessage } from './app-compliance-service.js'; +import { validateRepoSlug, buildRepoSlugMismatchMessage } from './repo-remote-validation.js'; import type { RunningFeature, PendingApproval, @@ -833,6 +834,24 @@ export class AutoModeService { throw new Error(message); } + // Repo-remote alignment gate. Refuse to run auto-mode when the project's + // `expectedRepoSlug` setting disagrees with the git remote origin at that path. + // Prevents cross-repo corruption where .automaker/ targets one repo but the + // git remote points to another. AUTOMAKER_SKIP_REPO_SLUG_CHECK bypasses. + const projectSettings = this.settingsService + ? await this.settingsService.getProjectSettings(projectPath).catch(() => null) + : null; + const slugValidation = await validateRepoSlug(projectPath, projectSettings?.expectedRepoSlug); + if (!slugValidation.valid && !slugValidation.skipped) { + const message = buildRepoSlugMismatchMessage( + projectPath, + slugValidation.expectedSlug!, + slugValidation.actualSlug + ); + logger.warn(`[repo-remote] ${message}`); + throw new Error(message); + } + // App-level pause gate. The durable `pausedProjects` registry is the enforced // source of truth — refuse to start a loop for a paused app until it is resumed. if (await this.isProjectPathPausedNow(projectPath)) { diff --git a/apps/server/src/services/project-health-service.ts b/apps/server/src/services/project-health-service.ts index b09fbdd0e..7dbdec289 100644 --- a/apps/server/src/services/project-health-service.ts +++ b/apps/server/src/services/project-health-service.ts @@ -19,6 +19,7 @@ import type { EventEmitter } from '../lib/events.js'; import type { ProjectService } from './project-service.js'; import type { FeatureLoader } from './feature-loader.js'; import type { SettingsService } from './settings-service.js'; +import { validateRepoSlug } from './repo-remote-validation.js'; const logger = createLogger('ProjectHealth'); @@ -55,6 +56,21 @@ export class ProjectHealthService { return project.health ?? 'on-track'; } + // Repo-remote alignment check — a misaligned project is always off-track. + const alignment = await this.checkRepoAlignment(projectPath); + if (alignment && !alignment.aligned) { + const targetHealth: ProjectHealth = 'off-track'; + if (project.health !== targetHealth) { + await this.projectService.updateProject(projectPath, projectSlug, { + health: targetHealth, + }); + logger.warn( + `Project "${projectSlug}" health -> off-track (repo mismatch: expected ${alignment.expected}, got ${alignment.actual})` + ); + } + return targetHealth; + } + const factors = await this.computeFactors(projectPath, project); const health = this.deriveHealth(factors); @@ -88,6 +104,46 @@ export class ProjectHealthService { } } + /** + * Check whether a project's git remote origin aligns with its declared + * `expectedRepoSlug` in project settings. + * + * Returns `null` when `expectedRepoSlug` is not configured (nothing to check). + * Returns `{ aligned: true }` when the remote matches. + * Returns `{ aligned: false, expected, actual }` on mismatch. + * + * This is a public, settings-service-free entry point so that the board UI + * or other services can query alignment without going through the full health + * compute pipeline. + */ + async checkRepoAlignment(projectPath: string): Promise<{ + aligned: boolean; + expected?: string; + actual?: string | null; + } | null> { + try { + const projectSettings = await this.settingsService + .getProjectSettings(projectPath) + .catch(() => null); + if (!projectSettings?.expectedRepoSlug) return null; + + const result = await validateRepoSlug(projectPath, projectSettings.expectedRepoSlug); + + if (result.notConfigured || result.skipped) { + return null; + } + + return { + aligned: result.valid, + expected: result.expectedSlug, + actual: result.actualSlug, + }; + } catch (err) { + logger.warn(`Failed to check repo alignment for ${projectPath}:`, err); + return null; + } + } + // ── Internal ───────────────────────────────────────────────────────────── /** diff --git a/apps/server/src/services/repo-remote-validation.ts b/apps/server/src/services/repo-remote-validation.ts new file mode 100644 index 000000000..efadd3ab7 --- /dev/null +++ b/apps/server/src/services/repo-remote-validation.ts @@ -0,0 +1,180 @@ +/** + * Repo-Remote Validation Service + * + * Validates that a project's git remote origin matches the expected repo slug + * declared in project settings (`expectedRepoSlug`). Prevents cross-repo + * corruption where `.automaker/` boards target one repo but the git remote + * points to another. + * + * Used as a startup gate in `startAutoLoopForProject` (like the compliance + * gate) and surfaced as a project-health warning on the board. + * + * Default: REFUSE auto-mode when `expectedRepoSlug` is set and origin disagrees. + * Escape hatch: set AUTOMAKER_SKIP_REPO_SLUG_CHECK=1 (truthy) to bypass. + * + * When `expectedRepoSlug` is NOT set, validation is skipped silently — no + * warning, no block. The field is the canonical declaration of intent. + */ + +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createLogger } from '@protolabsai/utils'; + +const execAsync = promisify(exec); +const logger = createLogger('RepoRemoteValidation'); + +/** Env var operators can set (truthy) to bypass the repo-slug gate entirely. */ +export const REPO_SLUG_SKIP_ENV = 'AUTOMAKER_SKIP_REPO_SLUG_CHECK'; + +/** Regex for HTTPS GitHub remotes: https://github.com/owner/repo.git */ +const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/; +/** Regex for SSH GitHub remotes: git@github.com:owner/repo.git */ +const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/; + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + +/** + * Extract the "owner/repo" slug from a git remote URL. + * Returns null if the URL is not a recognizable GitHub remote. + */ +export function extractRepoSlug(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + const httpsMatch = trimmed.match(GITHUB_HTTPS_REMOTE_REGEX); + if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`; + + const sshMatch = trimmed.match(GITHUB_SSH_REMOTE_REGEX); + if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`; + + return null; +} + +/** + * Read the git remote origin URL for a project path. + * Returns null if there is no origin remote or the command fails. + */ +export async function readGitRemoteOrigin(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git remote get-url origin', { + cwd: projectPath, + timeout: 10000, + }); + const url = stdout.trim(); + return url || null; + } catch { + // No origin remote, not a git repo, or timeout — all non-fatal + return null; + } +} + +/** + * Result of validating a project's git remote against its expected slug. + */ +export interface RepoSlugValidationResult { + /** True when validation passed or was skipped. */ + valid: boolean; + /** True when the gate was bypassed via the opt-out env var. */ + skipped: boolean; + /** True when `expectedRepoSlug` was not set (nothing to validate). */ + notConfigured: boolean; + /** The expected slug from settings, if any. */ + expectedSlug?: string; + /** The actual slug resolved from git remote origin, if any. */ + actualSlug?: string | null; + /** Human-readable mismatch message when valid is false. */ + mismatchMessage?: string; +} + +/** + * Build a clear, operator-facing refusal message from a slug mismatch. + */ +export function buildRepoSlugMismatchMessage( + projectPath: string, + expectedSlug: string, + actualSlug: string | null +): string { + const actual = actualSlug ?? '(no origin remote)'; + const lines = [ + `protoMaker refused to run auto-mode for ${projectPath}: git remote origin does not match the expected repo.`, + '', + ` Expected: ${expectedSlug}`, + ` Actual: ${actual}`, + '', + `This usually means the .automaker/ board at ${projectPath} targets a different repo`, + `than the git repository cloned at that path. Fix the mismatch by either:`, + ` 1. Re-cloning the correct repo at this path, or`, + ` 2. Moving the .automaker/ board to a path that matches the expected repo.`, + '', + `To bypass this gate (not recommended), set ${REPO_SLUG_SKIP_ENV}=1.`, + ]; + return lines.join('\n'); +} + +/** + * Validate that a project's git remote origin matches the expected repo slug + * from project settings. + * + * @param projectPath - Absolute path to the project directory + * @param expectedRepoSlug - The expected "owner/repo" slug from settings, or undefined to skip + * @returns Validation result + */ +export async function validateRepoSlug( + projectPath: string, + expectedRepoSlug?: string +): Promise { + const result: RepoSlugValidationResult = { + valid: true, + skipped: false, + notConfigured: !expectedRepoSlug, + expectedSlug: expectedRepoSlug, + actualSlug: null, + }; + + // If no expected slug is configured, skip silently + if (!expectedRepoSlug) { + return result; + } + + // Opt-out: don't fight people's systems + if (isTruthyEnv(process.env[REPO_SLUG_SKIP_ENV])) { + logger.info( + `[repo-remote] ${REPO_SLUG_SKIP_ENV} set — skipping repo slug validation for ${projectPath}` + ); + result.skipped = true; + return result; + } + + // Read the actual remote URL + const remoteUrl = await readGitRemoteOrigin(projectPath); + const actualSlug = remoteUrl ? extractRepoSlug(remoteUrl) : null; + result.actualSlug = actualSlug; + + // If we can't resolve the actual slug (non-GitHub remote or no remote), + // do NOT block — refusing to run a legitimate local/non-GitHub repo would + // be worse than the gap. Only block when we positively detect a mismatch. + if (!actualSlug) { + logger.info( + `[repo-remote] Cannot resolve repo slug from origin (${remoteUrl ?? 'null'}) for ${projectPath} — skipping validation` + ); + return result; + } + + // Compare slugs (case-insensitive — GitHub is case-insensitive for owner names) + if (actualSlug.toLowerCase() !== expectedRepoSlug.toLowerCase()) { + result.valid = false; + result.mismatchMessage = buildRepoSlugMismatchMessage( + projectPath, + expectedRepoSlug, + actualSlug + ); + logger.warn( + `[repo-remote] Mismatch detected for ${projectPath}: expected "${expectedRepoSlug}", got "${actualSlug}"` + ); + return result; + } + + return result; +} diff --git a/apps/server/tests/unit/services/repo-remote-validation.test.ts b/apps/server/tests/unit/services/repo-remote-validation.test.ts new file mode 100644 index 000000000..def75005c --- /dev/null +++ b/apps/server/tests/unit/services/repo-remote-validation.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the repo-remote validation service. + * + * Covers: + * - extractRepoSlug() for HTTPS and SSH GitHub URLs + * - readGitRemoteOrigin() success and failure + * - validateRepoSlug() — match, mismatch, not configured, skip env + * - buildRepoSlugMismatchMessage() content + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const h = vi.hoisted(() => ({ + remoteUrl: 'https://github.com/protoLabsAI/ava.git', + failGit: false, +})); + +vi.mock('@protolabsai/utils', async () => { + const actual = await vi.importActual('@protolabsai/utils'); + return { + ...(actual as object), + createLogger: () => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }), + }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + exec: ( + cmd: string, + opts: unknown, + cb?: (err: unknown, res?: { stdout: string; stderr: string }) => void + ) => { + const callback = typeof opts === 'function' ? (opts as typeof cb) : cb; + if (cmd.includes('git remote get-url origin')) { + if (h.failGit) return callback?.(new Error('git failed')); + return callback?.(null, { stdout: h.remoteUrl + '\n', stderr: '' }); + } + return callback?.(null, { stdout: '', stderr: '' }); + }, + }; +}); + +import { + extractRepoSlug, + readGitRemoteOrigin, + validateRepoSlug, + buildRepoSlugMismatchMessage, + REPO_SLUG_SKIP_ENV, +} from '@/services/repo-remote-validation.js'; + +describe('extractRepoSlug()', () => { + it('extracts slug from HTTPS GitHub URL', () => { + expect(extractRepoSlug('https://github.com/owner/repo.git')).toBe('owner/repo'); + expect(extractRepoSlug('https://github.com/owner/repo')).toBe('owner/repo'); + }); + + it('extracts slug from SSH GitHub URL', () => { + expect(extractRepoSlug('git@github.com:owner/repo.git')).toBe('owner/repo'); + expect(extractRepoSlug('git@github.com:owner/repo')).toBe('owner/repo'); + }); + + it('returns null for non-GitHub URLs', () => { + expect(extractRepoSlug('https://gitlab.com/owner/repo.git')).toBeNull(); + expect(extractRepoSlug('ssh://git@internal.example.com/repo.git')).toBeNull(); + expect(extractRepoSlug('file:///local/path')).toBeNull(); + }); + + it('trims whitespace', () => { + expect(extractRepoSlug(' https://github.com/owner/repo.git ')).toBe('owner/repo'); + }); +}); + +describe('readGitRemoteOrigin()', () => { + it('reads the origin URL from git', async () => { + h.remoteUrl = 'https://github.com/test/repo.git'; + const url = await readGitRemoteOrigin('/some/path'); + expect(url).toBe('https://github.com/test/repo.git'); + }); + + it('returns null when git command fails', async () => { + h.failGit = true; + const url = await readGitRemoteOrigin('/some/path'); + expect(url).toBeNull(); + }); + + it('returns null for non-GitHub remotes', async () => { + h.remoteUrl = 'https://gitlab.com/owner/repo.git'; + const url = await readGitRemoteOrigin('/some/path'); + expect(url).toBe('https://gitlab.com/owner/repo.git'); + }); +}); + +describe('validateRepoSlug()', () => { + beforeEach(() => { + h.remoteUrl = 'https://github.com/protoLabsAI/ava.git'; + h.failGit = false; + delete process.env[REPO_SLUG_SKIP_ENV]; + }); + afterEach(() => { + delete process.env[REPO_SLUG_SKIP_ENV]; + }); + + it('returns notConfigured when expectedRepoSlug is undefined', async () => { + const result = await validateRepoSlug('/path'); + expect(result.notConfigured).toBe(true); + expect(result.valid).toBe(true); + }); + + it('returns valid when slugs match', async () => { + const result = await validateRepoSlug('/path', 'protoLabsAI/ava'); + expect(result.valid).toBe(true); + expect(result.notConfigured).toBe(false); + expect(result.actualSlug).toBe('protoLabsAI/ava'); + }); + + it('is case-insensitive for owner names', async () => { + const result = await validateRepoSlug('/path', 'ProtolabsAI/Ava'); + expect(result.valid).toBe(true); + }); + + it('returns invalid when slugs mismatch', async () => { + const result = await validateRepoSlug('/path', 'protoLabsAI/other-repo'); + expect(result.valid).toBe(false); + expect(result.expectedSlug).toBe('protoLabsAI/other-repo'); + expect(result.actualSlug).toBe('protoLabsAI/ava'); + expect(result.mismatchMessage).toBeDefined(); + }); + + it('skips when opt-out env var is set', async () => { + process.env[REPO_SLUG_SKIP_ENV] = '1'; + const result = await validateRepoSlug('/path', 'protoLabsAI/other-repo'); + expect(result.skipped).toBe(true); + expect(result.valid).toBe(true); + }); + + it('does NOT block when actual slug cannot be resolved (non-GitHub remote)', async () => { + h.remoteUrl = 'https://gitlab.com/owner/repo.git'; + const result = await validateRepoSlug('/path', 'protoLabsAI/ava'); + expect(result.valid).toBe(true); // non-GitHub remote → skip, don't block + expect(result.actualSlug).toBeNull(); + }); + + it('does NOT block when git command fails (no remote)', async () => { + h.failGit = true; + const result = await validateRepoSlug('/path', 'protoLabsAI/ava'); + expect(result.valid).toBe(true); // can't determine → don't block + expect(result.actualSlug).toBeNull(); + }); +}); + +describe('buildRepoSlugMismatchMessage()', () => { + it('includes expected and actual slugs', () => { + const msg = buildRepoSlugMismatchMessage('/path', 'owner/expected', 'owner/actual'); + expect(msg).toContain('/path'); + expect(msg).toContain('owner/expected'); + expect(msg).toContain('owner/actual'); + }); + + it('handles null actual slug', () => { + const msg = buildRepoSlugMismatchMessage('/path', 'owner/expected', null); + expect(msg).toContain('(no origin remote)'); + }); + + it('mentions the opt-out env var', () => { + const msg = buildRepoSlugMismatchMessage('/path', 'owner/expected', 'owner/actual'); + expect(msg).toContain(REPO_SLUG_SKIP_ENV); + }); +}); + +describe('Concurrency & race guard: multiple parallel validations', () => { + it('handles concurrent validations for same project without corruption', async () => { + const n = 8; + const promises = Array.from({ length: n }, () => validateRepoSlug('/path', 'protoLabsAI/ava')); + const results = await Promise.all(promises); + expect(results).toHaveLength(n); + for (const r of results) { + expect(r.valid).toBe(true); + expect(r.actualSlug).toBe('protoLabsAI/ava'); + } + }); + + it('handles concurrent mismatch validations deterministically', async () => { + const n = 5; + const promises = Array.from({ length: n }, () => + validateRepoSlug('/path', 'protoLabsAI/other-repo') + ); + const results = await Promise.all(promises); + expect(results).toHaveLength(n); + for (const r of results) { + expect(r.valid).toBe(false); + expect(r.expectedSlug).toBe('protoLabsAI/other-repo'); + expect(r.actualSlug).toBe('protoLabsAI/ava'); + } + }); +}); diff --git a/libs/types/src/project-settings.ts b/libs/types/src/project-settings.ts index e09a55569..204263e10 100644 --- a/libs/types/src/project-settings.ts +++ b/libs/types/src/project-settings.ts @@ -291,6 +291,18 @@ export interface ProjectSettings { * as a global server overrides it for this project. */ mcpServers?: MCPServerConfig[]; + + // Repository Alignment (per-project) + /** + * Expected GitHub repo slug in "owner/repo" form (e.g. "protoLabsAI/ava"). + * When set, auto-mode validates that the git remote origin at this project's + * path resolves to the same slug. If they disagree, auto-mode refuses to start + * and surfaces a project-health warning on the board. + * + * This prevents cross-repo corruption where the .automaker/ board targets one + * repo but the git remote points to another. + */ + expectedRepoSlug?: string; } /** Default project settings (empty - all settings are optional and fall back to global) */