Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/server/src/services/auto-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment on lines +841 to +843

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/auto-mode-service.ts` around lines 841 - 843, The
current silent swallow of settings read errors via .catch(() => null) lets
projectSettings be null and bypass the repo-slug alignment gate; change this to
fail-closed by removing the silent catch so that
settingsService.getProjectSettings(projectPath) propagates errors (or explicitly
throw/log and rethrow) instead of returning null, ensuring the repo-slug check
in the auto-mode startup path runs only when settings were read successfully or
requires an explicit operator override flag; update code around projectSettings,
settingsService.getProjectSettings and the repo-slug alignment guard to handle
the propagated error path (or check an explicit override) so transient/corrupt
reads do not enable auto-mode.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix build-breaking TS type mismatch for actualSlug

apps/server/src/services/auto-mode-service.ts (around lines 846-850): slugValidation.actualSlug is typed as string | null | undefined (actualSlug?: string | null), but buildRepoSlugMismatchMessage(..., actualSlug: string | null) rejects undefined, matching the CI TS2345 failure.

💡 Suggested fix
       const message = buildRepoSlugMismatchMessage(
         projectPath,
         slugValidation.expectedSlug!,
-        slugValidation.actualSlug
+        slugValidation.actualSlug ?? null
       );

Additionally, the settings read uses .catch(() => null); if that causes expectedRepoSlug to be missing, confirm validateRepoSlug() can’t mark the mismatch gate as “valid/skipped” in that case.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const message = buildRepoSlugMismatchMessage(
projectPath,
slugValidation.expectedSlug!,
slugValidation.actualSlug
);
const message = buildRepoSlugMismatchMessage(
projectPath,
slugValidation.expectedSlug!,
slugValidation.actualSlug ?? null
);
🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/auto-mode-service.ts` around lines 846 - 850, The
call to buildRepoSlugMismatchMessage passes slugValidation.actualSlug which is
typed as string | null | undefined causing a TS2345 error; ensure actualSlug is
narrowed or defaulted to a non-undefined string before calling
buildRepoSlugMismatchMessage (e.g., coalesce undefined to null or an empty
string) so its type matches the function signature, and update the call site in
auto-mode-service.ts where buildRepoSlugMismatchMessage is invoked; also inspect
validateRepoSlug() and the settings read that uses .catch(() => null) (which can
make expectedRepoSlug undefined) and ensure validateRepoSlug() treats a missing
expectedRepoSlug as skipped/valid or otherwise handle the missing value before
building the mismatch message.

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)) {
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/services/project-health-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────────────────

/**
Expand Down
180 changes: 180 additions & 0 deletions apps/server/src/services/repo-remote-validation.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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' || true

Repository: protoLabsAI/protoMaker

Length of output: 227


🏁 Script executed:

#!/bin/bash
set -euo pipefail

Repository: 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 || true

Repository: protoLabsAI/protoMaker

Length of output: 9850


Fix GitHub remote slug parsing to handle dotted repo names and ssh:// remotes.

  • In apps/server/src/services/repo-remote-validation.ts, the repo segment regex uses ([^/.]+), which truncates repo names containing dots (e.g. my.repo becomes my) causing false mismatches.
  • The SSH regex only matches git@github.com:owner/repo(.git); ssh://git@github.com/owner/repo(.git) doesn’t match, so extractRepoSlug() returns null and the repo-slug gate gets skipped.
💡 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/repo-remote-validation.ts` around lines 30 - 33, The
current regexes GITHUB_HTTPS_REMOTE_REGEX and GITHUB_SSH_REMOTE_REGEX used by
extractRepoSlug truncate repo names with dots and miss ssh:// style remotes;
update both regexes to use a repo capture that allows dots (e.g. use [^/]+ for
the repo segment instead of ([^/.]+)), make the repo suffix optionally end with
.git, and expand the SSH pattern to accept both git@github.com:owner/repo and
ssh://git@github.com/owner/repo forms (allow either ":" or "/" after the host
and an optional "ssh://" prefix). Ensure extractRepoSlug continues to use these
constants so it correctly returns owner/repo slugs for dotted names and ssh://
remotes.

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;
}
Loading
Loading