-
-
Notifications
You must be signed in to change notification settings - Fork 2
[github] bug: project path → git remote misalignment corrupts cross-repo features #4127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
Comment on lines
+846
to
+850
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix build-breaking TS type mismatch for
💡 Suggested fix const message = buildRepoSlugMismatchMessage(
projectPath,
slugValidation.expectedSlug!,
- slugValidation.actualSlug
+ slugValidation.actualSlug ?? null
);Additionally, the settings read uses 📝 Committable suggestion
Suggested change
🧰 Tools🪛 GitHub Actions: PR Build Check / 1_build.txt[error] 849-849: TypeScript (tsc) error TS2345: Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | null'. 🪛 GitHub Actions: PR Build Check / build[error] 849-849: TypeScript (tsc) error TS2345: Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | null'. 🤖 Prompt for AI Agents |
||||||||||||||||||||||
| 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)) { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:([^/]+)\/([^/.]+)/; | ||
|
|
||
|
Comment on lines
+30
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate the file and print the relevant sections
FILE="apps/server/src/services/repo-remote-validation.ts"
ls -l "$FILE" || (echo "Missing $FILE" && exit 1)
# Show around the regex definitions (lines 1-120 is usually safe)
nl -ba "$FILE" | sed -n '1,180p'
# Find all usages of the regex constants in this file
rg -n "GITHUB_(HTTPS|SSH)_REMOTE_REGEX" "$FILE"
# If the file is small, also dump rest to understand flow
wc -l "$FILE"
nl -ba "$FILE" | sed -n '180,400p' || trueRepository: protoLabsAI/protoMaker Length of output: 227 🏁 Script executed: #!/bin/bash
set -euo pipefailRepository: owner/repo Length of output: 2026 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="apps/server/src/services/repo-remote-validation.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }
echo "== File length =="
wc -l "$FILE"
echo "== Regex definitions area (lines 1-120) =="
awk 'NR>=1 && NR<=120 {printf "%d\t%s\n", NR, $0}' "$FILE"
echo "== Find extractRepoSlug / usage of regexes =="
rg -n "extractRepoSlug|GITHUB_HTTPS_REMOTE_REGEX|GITHUB_SSH_REMOTE_REGEX|remote|slug" "$FILE"
echo "== Show extractRepoSlug implementation block (best-effort) =="
# If present, print around its definition
rg -n "function extractRepoSlug|extractRepoSlug\s*=" "$FILE" || true
# Print more context around possible match blocks
awk 'NR>=120 && NR<=320 {printf "%d\t%s\n", NR, $0}' "$FILE" 2>/dev/null || trueRepository: protoLabsAI/protoMaker Length of output: 9850 Fix GitHub remote slug parsing to handle dotted repo names and
💡 Suggested fix-const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/;
-const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/;
+const GITHUB_HTTPS_REMOTE_REGEX =
+ /^https:\/\/(?:[^`@/`]+@)?github\.com\/([^/]+)\/([^/\s]+?)(?:\.git)?$/i;
+const GITHUB_SSH_REMOTE_REGEX =
+ /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([^/]+)\/([^/\s]+?)(?:\.git)?$/i;🤖 Prompt for AI Agents |
||
| 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<string | null> { | ||
| 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<RepoSlugValidationResult> { | ||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Settings read failures currently bypass the alignment gate (fail-open).
At Line 842,
.catch(() => null)means a transient/corrupt settings read silently disables the repo-slug check and allows auto-mode startup. For this guard, fail-closed behavior is safer (or require explicit operator override).🤖 Prompt for AI Agents