From 765cb965e695ecc25a1782840ad0a307488b0e5a Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 22 May 2026 21:01:59 +0000 Subject: [PATCH 1/4] fix: sanitize template variables before YAML validation --- apps/api/src/api/tool_types.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 113c353..4c459aa 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -1,3 +1,4 @@ +import re import uuid from datetime import datetime @@ -7,6 +8,11 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession + +def _sanitize_template_vars(template: str) -> str: + """Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors.""" + return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template) + from src.auth.dependencies import get_current_user_id, get_db_session from src.models.tool_type import ToolType from src.models.user import User @@ -65,8 +71,12 @@ class ToolTypeCreate(BaseModel): if v is None: raise ValueError("compose_template is required when definition_type is 'compose'") + # Replace template variables with dummy values before YAML validation + # to avoid YAML parsing errors with {{VAR}} syntax + sanitized = _sanitize_template_vars(v) + try: - parsed = yaml.safe_load(v) + parsed = yaml.safe_load(sanitized) except yaml.YAMLError as e: raise ValueError(f"Invalid YAML: {e}") @@ -145,7 +155,8 @@ class ToolTypeCreate(BaseModel): # Validate that default_port is exposed in compose template (only if requires_port) if self.requires_port and self.definition_type == "compose" and self.compose_template: try: - parsed = yaml.safe_load(self.compose_template) + sanitized = _sanitize_template_vars(self.compose_template) + parsed = yaml.safe_load(sanitized) except yaml.YAMLError: return self @@ -215,8 +226,11 @@ class ToolTypeUpdate(BaseModel): if definition_type and definition_type != "compose": return v + # Replace template variables with dummy values before YAML validation + sanitized = _sanitize_template_vars(v) + try: - parsed = yaml.safe_load(v) + parsed = yaml.safe_load(sanitized) except yaml.YAMLError as e: raise ValueError(f"Invalid YAML: {e}") @@ -427,7 +441,8 @@ async def update_tool_type( template = update_data.get("compose_template", tool_type.compose_template) if template: try: - parsed = yaml.safe_load(template) + sanitized = _sanitize_template_vars(template) + parsed = yaml.safe_load(sanitized) except yaml.YAMLError: parsed = None @@ -518,7 +533,8 @@ async def validate_tool_type_template( errors.append("Compose template is required") else: try: - parsed = yaml.safe_load(data.compose_template) + sanitized = _sanitize_template_vars(data.compose_template) + parsed = yaml.safe_load(sanitized) if not isinstance(parsed, dict): errors.append("Compose template must be a YAML mapping") elif "services" not in parsed: @@ -575,7 +591,8 @@ async def validate_tool_type( errors.append("Compose template is empty") else: try: - parsed = yaml.safe_load(tool_type.compose_template) + sanitized = _sanitize_template_vars(tool_type.compose_template) + parsed = yaml.safe_load(sanitized) if not isinstance(parsed, dict): errors.append("Compose template must be a YAML mapping") elif "services" not in parsed: From dc8ef0e463ddc385eb4c407921e7a6d7b6b29b73 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 22 May 2026 21:12:46 +0000 Subject: [PATCH 2/4] fix: chain clone_mode migration after single_interface migration --- apps/api/alembic/versions/2026_05_22_add_clone_mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/alembic/versions/2026_05_22_add_clone_mode.py b/apps/api/alembic/versions/2026_05_22_add_clone_mode.py index eca0b6d..2f490c1 100644 --- a/apps/api/alembic/versions/2026_05_22_add_clone_mode.py +++ b/apps/api/alembic/versions/2026_05_22_add_clone_mode.py @@ -11,7 +11,7 @@ from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = '2026_05_22_add_clone_mode' -down_revision = '0014_merge_heads' +down_revision = '0015_single_interface' branch_labels = None depends_on = None From 4ef0f108ea6b4c412cd1f974f5bd82c8e334b630 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 22 May 2026 21:19:15 +0000 Subject: [PATCH 3/4] fix: use SSH key during repository preflight and clone --- apps/api/src/api/git_repositories.py | 68 +++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index e5045fb..6d0e8a6 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -35,6 +35,7 @@ from src.utils.git_control import ( ) from src.utils.git_history import get_commit_detail, get_commit_history from src.utils.git_url_parser import parse_git_url +from src.services.ssh_keys import _get_fernet router = APIRouter(prefix="/projects", tags=["git-repositories"]) @@ -95,19 +96,61 @@ def _build_provider_clone_url(owner: str, repo: str) -> str: return f"git@git.commumedia.org:{owner}/{repo}.git" -def _preflight_remote_repository(remote_url: str) -> None: +def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None: + """Prepare environment variables for git commands with SSH authentication. + + Returns a dict of extra env vars, or None if no SSH key provided. + The caller is responsible for cleaning up the temporary key file. + """ + if ssh_key is None: + return None + + import tempfile + + # Decrypt private key + fernet = _get_fernet() + private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() + + # Write to temp file with restricted permissions + fd, key_path = tempfile.mkstemp(prefix="ssh_key_") + try: + os.write(fd, private_key.encode()) + finally: + os.close(fd) + os.chmod(key_path, 0o600) + + # Return env vars and the key path for cleanup + env = { + "GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + } + return env, key_path + + +def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None: """Verify a remote repository is reachable before cloning.""" + env = None + key_path = None + + if ssh_key is not None: + ssh_result = _prepare_ssh_env(ssh_key) + if ssh_result: + env, key_path = ssh_result + try: result = subprocess.run( ["git", "ls-remote", remote_url], capture_output=True, text=True, timeout=60, + env={**os.environ, **env} if env else None, ) except subprocess.TimeoutExpired: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") except FileNotFoundError: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) if result.returncode != 0: raise HTTPException( @@ -116,18 +159,30 @@ def _preflight_remote_repository(remote_url: str) -> None: ) -def _clone_working_repository(remote_url: str, repo_path: str) -> None: +def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None: + env = None + key_path = None + + if ssh_key is not None: + ssh_result = _prepare_ssh_env(ssh_key) + if ssh_result: + env, key_path = ssh_result + try: result = subprocess.run( ["git", "clone", remote_url, repo_path], capture_output=True, text=True, timeout=300, + env={**os.environ, **env} if env else None, ) except subprocess.TimeoutExpired: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") except FileNotFoundError: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + finally: + if key_path and os.path.exists(key_path): + os.unlink(key_path) if result.returncode != 0: raise HTTPException( @@ -352,11 +407,9 @@ async def create_repository( if parse_result["base_url"]: remote_url = parse_result["base_url"] - if remote_url: - _preflight_remote_repository(remote_url) - # Validate SSH key if provided ssh_key_id = None + ssh_key = None if data.ssh_key_id: try: ssh_key_id = uuid.UUID(data.ssh_key_id) @@ -369,13 +422,16 @@ async def create_repository( if ssh_key.user_id != user_id and ssh_key.project_id != project_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project") + if remote_url: + _preflight_remote_repository(remote_url, ssh_key) + repo_path = _get_repo_path(user_id, project_id, data.name) # Ensure parent directory exists os.makedirs(os.path.dirname(repo_path), exist_ok=True) if remote_url: - _clone_working_repository(remote_url, repo_path) + _clone_working_repository(remote_url, repo_path, ssh_key) else: _init_working_repository(repo_path) From ca8b255148ac895c6133ecab60ed6ece9d486eb3 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 22 May 2026 21:21:45 +0000 Subject: [PATCH 4/4] fix: log detailed error messages in repository preflight and clone --- apps/api/src/api/git_repositories.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 6d0e8a6..5190afe 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -153,9 +153,10 @@ def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) os.unlink(key_path) if result.returncode != 0: + logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="repository not found or inaccessible", + detail=f"repository not found or inaccessible: {result.stderr}", ) @@ -185,6 +186,7 @@ def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | os.unlink(key_path) if result.returncode != 0: + logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"failed to clone repository: {result.stderr}",