From 063a839790e4076e3a1bddc4205dabcc85da1166 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 22 May 2026 22:56:35 +0200 Subject: [PATCH] feat: implement repository clone mode with SSH key support - Add clone_mode and branch fields to tool_instances - Add ssh_key_id to git_repositories for per-repo SSH key assignment - Implement host-side git cloning with branch selection (default: main) - Mount SSH keys into containers for git operations in clone mode - Add dirty state check on clone-mode instance deletion with confirmation - Update SessionsPage with mount/clone selector, branch input, SSH key display - Add SSH key selector to repository creation form - Add dirty delete confirmation modal with changed files list - Update API schemas and endpoints for new fields - Sync delta specs to main specs (git-repo, tool-instances, repo-clone-mode) - Archive completed OpenSpec change: repo-clone-mode-with-ssh - Document git requirement for custom tool types Quality gates: Frontend typecheck and build passed OpenSpec: repo-clone-mode-with-ssh archived with all tasks complete --- .../versions/2026_05_22_add_clone_mode.py | 36 ++++ apps/api/src/api/git_repositories.py | 76 ++++++++ apps/api/src/api/tool_instances.py | 101 ++++++++++- apps/api/src/models/git_repository.py | 5 + apps/api/src/models/tool_instance.py | 6 + apps/api/src/services/clone.py | 97 ++++++++++ apps/api/src/services/ssh_keys.py | 73 ++++++++ apps/web/src/api/git_repositories.ts | 14 ++ apps/web/src/api/sessions.ts | 14 +- .../components/repository-create-dialog.tsx | 126 +++++++++---- apps/web/src/pages/sessions.tsx | 169 +++++++++++++++++- apps/web/src/pages/settings.tsx | 4 +- apps/web/src/router.tsx | 6 - docs/features/tool-types.md | 11 ++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 22 +++ .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/instance-runtime-health/spec.md | 0 .../specs/instance-startup-health/spec.md | 0 .../specs/readiness-probe-integration/spec.md | 0 .../specs/smart-tunnel-recovery/spec.md | 0 .../specs/tool-instances/spec.md | 0 .../tasks.md | 0 .../2026-05-22-instance-proxy}/.openspec.yaml | 0 .../2026-05-22-instance-proxy}/design.md | 0 .../2026-05-22-instance-proxy}/proposal.md | 0 .../specs/instance-proxy/spec.md | 0 .../2026-05-22-instance-proxy/tasks.md | 26 +++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/opencode-web-server/spec.md | 0 .../specs/tool-port-configuration/spec.md | 0 .../specs/tool-types/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 101 +++++++++++ .../proposal.md | 33 ++++ .../specs/git-repo/spec.md | 29 +++ .../specs/repo-clone-mode/spec.md | 39 ++++ .../specs/tool-instances/spec.md | 54 ++++++ .../tasks.md | 84 +++++++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/session-lifecycle-ux/spec.md | 0 .../specs/tool-instances/spec.md | 0 .../specs/tunnel-health-monitoring/spec.md | 0 .../tasks.md | 0 .../.openspec/config.yaml | 0 .../2026-05-22-sessions-hub}/design.md | 0 .../2026-05-22-sessions-hub}/proposal.md | 0 .../2026-05-22-sessions-hub}/specs/spec.md | 0 .../2026-05-22-sessions-hub}/tasks.md | 36 ++-- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/tool-config-management/spec.md | 0 .../tasks.md | 42 +++++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/tool-config-management/spec.md | 0 .../2026-05-22-tool-config-ui-rework/tasks.md | 59 ++++++ .../.openspec.yaml | 2 + .../design.md | 30 ++-- .../proposal.md | 0 .../tasks.md | 35 ++++ .../changes/git-repo-ssh-clone-check/tasks.md | 22 --- openspec/changes/instance-proxy/tasks.md | 26 --- .../changes/tool-config-management/tasks.md | 42 ----- .../changes/tool-config-ui-rework/tasks.md | 59 ------ .../ui-redesign-home-settings/tasks.md | 34 ---- openspec/specs/git-repo/spec.md | 21 ++- openspec/specs/instance-proxy/spec.md | 41 +++++ .../specs/instance-runtime-health/spec.md | 57 ++++++ .../specs/instance-startup-health/spec.md | 83 +++++++++ openspec/specs/opencode-web-server/spec.md | 39 ++++ .../specs/readiness-probe-integration/spec.md | 51 ++++++ openspec/specs/repo-clone-mode/spec.md | 59 ++++++ openspec/specs/session-lifecycle-ux/spec.md | 34 ++++ openspec/specs/sessions-hub/spec.md | 115 ++++++++++++ openspec/specs/smart-tunnel-recovery/spec.md | 45 +++++ openspec/specs/tool-config-management/spec.md | 53 ++++++ openspec/specs/tool-instances/spec.md | 163 +++++++++-------- .../specs/tool-port-configuration/spec.md | 38 ++++ openspec/specs/tool-types/spec.md | 47 ++--- .../specs/tunnel-health-monitoring/spec.md | 35 ++++ 95 files changed, 2002 insertions(+), 392 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_22_add_clone_mode.py create mode 100644 apps/api/src/services/clone.py create mode 100644 apps/api/src/services/ssh_keys.py rename openspec/changes/{git-repo-ssh-clone-check => archive/2026-05-22-git-repo-ssh-clone-check}/.openspec.yaml (100%) rename openspec/changes/{git-repo-ssh-clone-check => archive/2026-05-22-git-repo-ssh-clone-check}/design.md (100%) rename openspec/changes/{git-repo-ssh-clone-check => archive/2026-05-22-git-repo-ssh-clone-check}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-git-repo-ssh-clone-check/tasks.md rename openspec/changes/{git-repo-working-clones => archive/2026-05-22-git-repo-working-clones}/design.md (100%) rename openspec/changes/{git-repo-working-clones => archive/2026-05-22-git-repo-working-clones}/proposal.md (100%) rename openspec/changes/{git-repo-working-clones => archive/2026-05-22-git-repo-working-clones}/tasks.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/.openspec.yaml (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/design.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/proposal.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/specs/instance-runtime-health/spec.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/specs/instance-startup-health/spec.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/specs/readiness-probe-integration/spec.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/specs/smart-tunnel-recovery/spec.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/specs/tool-instances/spec.md (100%) rename openspec/changes/{instance-health-monitoring => archive/2026-05-22-instance-health-monitoring}/tasks.md (100%) rename openspec/changes/{instance-proxy => archive/2026-05-22-instance-proxy}/.openspec.yaml (100%) rename openspec/changes/{instance-proxy => archive/2026-05-22-instance-proxy}/design.md (100%) rename openspec/changes/{instance-proxy => archive/2026-05-22-instance-proxy}/proposal.md (100%) rename openspec/changes/{instance-proxy => archive/2026-05-22-instance-proxy}/specs/instance-proxy/spec.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-instance-proxy/tasks.md rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/.openspec.yaml (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/design.md (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/proposal.md (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/specs/opencode-web-server/spec.md (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/specs/tool-port-configuration/spec.md (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/specs/tool-types/spec.md (100%) rename openspec/changes/{opencode-web-terminal => archive/2026-05-22-opencode-web-terminal}/tasks.md (100%) rename openspec/changes/{ui-redesign-home-settings => archive/2026-05-22-repo-clone-mode-with-ssh}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/design.md create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/proposal.md create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/specs/git-repo/spec.md create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/specs/repo-clone-mode/spec.md create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/specs/tool-instances/spec.md create mode 100644 openspec/changes/archive/2026-05-22-repo-clone-mode-with-ssh/tasks.md rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/.openspec.yaml (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/design.md (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/proposal.md (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/specs/session-lifecycle-ux/spec.md (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/specs/tool-instances/spec.md (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/specs/tunnel-health-monitoring/spec.md (100%) rename openspec/changes/{session-management-fixes => archive/2026-05-22-session-management-fixes}/tasks.md (100%) rename openspec/changes/{sessions-hub => archive/2026-05-22-sessions-hub}/.openspec/config.yaml (100%) rename openspec/changes/{sessions-hub => archive/2026-05-22-sessions-hub}/design.md (100%) rename openspec/changes/{sessions-hub => archive/2026-05-22-sessions-hub}/proposal.md (100%) rename openspec/changes/{sessions-hub => archive/2026-05-22-sessions-hub}/specs/spec.md (100%) rename openspec/changes/{sessions-hub => archive/2026-05-22-sessions-hub}/tasks.md (67%) rename openspec/changes/{tool-config-management => archive/2026-05-22-tool-config-management}/.openspec.yaml (100%) rename openspec/changes/{tool-config-management => archive/2026-05-22-tool-config-management}/design.md (100%) rename openspec/changes/{tool-config-management => archive/2026-05-22-tool-config-management}/proposal.md (100%) rename openspec/changes/{tool-config-management => archive/2026-05-22-tool-config-management}/specs/tool-config-management/spec.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-tool-config-management/tasks.md rename openspec/changes/{tool-config-ui-rework => archive/2026-05-22-tool-config-ui-rework}/.openspec.yaml (100%) rename openspec/changes/{tool-config-ui-rework => archive/2026-05-22-tool-config-ui-rework}/design.md (100%) rename openspec/changes/{tool-config-ui-rework => archive/2026-05-22-tool-config-ui-rework}/proposal.md (100%) rename openspec/changes/{tool-config-ui-rework => archive/2026-05-22-tool-config-ui-rework}/specs/tool-config-management/spec.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-tool-config-ui-rework/tasks.md create mode 100644 openspec/changes/archive/2026-05-22-ui-redesign-home-settings/.openspec.yaml rename openspec/changes/{ui-redesign-home-settings => archive/2026-05-22-ui-redesign-home-settings}/design.md (75%) rename openspec/changes/{ui-redesign-home-settings => archive/2026-05-22-ui-redesign-home-settings}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-05-22-ui-redesign-home-settings/tasks.md delete mode 100644 openspec/changes/git-repo-ssh-clone-check/tasks.md delete mode 100644 openspec/changes/instance-proxy/tasks.md delete mode 100644 openspec/changes/tool-config-management/tasks.md delete mode 100644 openspec/changes/tool-config-ui-rework/tasks.md delete mode 100644 openspec/changes/ui-redesign-home-settings/tasks.md create mode 100644 openspec/specs/instance-proxy/spec.md create mode 100644 openspec/specs/instance-runtime-health/spec.md create mode 100644 openspec/specs/instance-startup-health/spec.md create mode 100644 openspec/specs/opencode-web-server/spec.md create mode 100644 openspec/specs/readiness-probe-integration/spec.md create mode 100644 openspec/specs/repo-clone-mode/spec.md create mode 100644 openspec/specs/session-lifecycle-ux/spec.md create mode 100644 openspec/specs/sessions-hub/spec.md create mode 100644 openspec/specs/smart-tunnel-recovery/spec.md create mode 100644 openspec/specs/tool-config-management/spec.md create mode 100644 openspec/specs/tool-port-configuration/spec.md create mode 100644 openspec/specs/tunnel-health-monitoring/spec.md 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 new file mode 100644 index 0000000..eca0b6d --- /dev/null +++ b/apps/api/alembic/versions/2026_05_22_add_clone_mode.py @@ -0,0 +1,36 @@ +"""add_clone_mode_and_ssh_key_id + +Revision ID: 2026_05_22_add_clone_mode +Revises: 0014_merge_heads +Create Date: 2026-05-22 20:30:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2026_05_22_add_clone_mode' +down_revision = '0014_merge_heads' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add ssh_key_id to git_repositories + op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True)) + op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id']) + + # Add clone_mode and branch to tool_instances + op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount')) + op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main')) + + +def downgrade() -> None: + # Drop columns from tool_instances + op.drop_column('tool_instances', 'branch') + op.drop_column('tool_instances', 'clone_mode') + + # Drop ssh_key_id from git_repositories + op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey') + op.drop_column('git_repositories', 'ssh_key_id') diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 8c2bb0e..e5045fb 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -14,6 +14,7 @@ from src.auth.dependencies import get_current_user_id, get_db_session from src.config import Settings from src.models.git_repository import GitRepository from src.models.project import Project +from src.models.ssh_key import SSHKey from src.models.user import User from src.utils.git_files import ( commit_file, @@ -175,6 +176,7 @@ class GitRepositoryCreate(BaseModel): name: str remote_url: str | None = None force_original_url: bool = False + ssh_key_id: str | None = None class URLParseRequest(BaseModel): @@ -202,6 +204,7 @@ class GitRepositoryResponse(BaseModel): is_mirror: bool remote_url: str | None last_push: datetime | None + ssh_key_id: uuid.UUID | None created_at: datetime updated_at: datetime @@ -352,6 +355,20 @@ async def create_repository( if remote_url: _preflight_remote_repository(remote_url) + # Validate SSH key if provided + ssh_key_id = None + if data.ssh_key_id: + try: + ssh_key_id = uuid.UUID(data.ssh_key_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") + + ssh_key = await session.get(SSHKey, ssh_key_id) + if ssh_key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + 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") + repo_path = _get_repo_path(user_id, project_id, data.name) # Ensure parent directory exists @@ -369,6 +386,7 @@ async def create_repository( owner_id=user_id, is_mirror=False, remote_url=remote_url, + ssh_key_id=ssh_key_id, ) session.add(repo) await session.commit() @@ -376,6 +394,64 @@ async def create_repository( return repo +class UpdateSSHKeyRequest(BaseModel): + ssh_key_id: str | None = None + + +@router.patch( + "/{project_id}/repositories/{repo_id}/ssh-key", + response_model=GitRepositoryResponse, + summary="Update repository SSH key", + description="Update the SSH key associated with a repository.", +) +async def update_repository_ssh_key( + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: UpdateSSHKeyRequest, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> GitRepository: + """Update the SSH key for a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Update data containing the new SSH key ID. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated repository. + """ + _user = await _get_user(session, user_id) + _project = await _get_owned_project(project_id, user_id, session) + + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + + # Validate SSH key if provided + if data.ssh_key_id: + try: + ssh_key_id = uuid.UUID(data.ssh_key_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format") + + ssh_key = await session.get(SSHKey, ssh_key_id) + if ssh_key is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + 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") + + repo.ssh_key_id = ssh_key_id + else: + repo.ssh_key_id = None + + await session.commit() + await session.refresh(repo) + return repo + + @router.get( "/{project_id}/repositories/{repo_id}/history", summary="Get repository history", diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index e8805b4..a41efdc 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -18,6 +18,7 @@ from src.auth.dependencies import get_current_user_id from src.auth.dependencies import get_db_session from src.models.git_repository import GitRepository from src.models.project import Project +from src.models.ssh_key import SSHKey from src.models.tool_config import ToolConfig from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType @@ -43,8 +44,10 @@ from src.services.docker import ( write_env_file, write_config_folder_files, ) +from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory from src.services.docker_build import build_image from src.services.readiness_probe import execute_probe +from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -56,6 +59,8 @@ class CreateInstanceRequest(BaseModel): tool_type_id: str = Field(description="UUID of the tool type to instantiate") display_name: str | None = Field(default=None, description="Optional display name for the instance") + clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") + branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") def _modify_compose_file( @@ -193,6 +198,19 @@ async def create_instance( ) try: + # Validate clone mode requirements + if data.clone_mode == "clone": + if not repo.remote_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository does not have a remote URL for cloning" + ) + if not repo.ssh_key_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository must have an SSH key assigned for clone mode" + ) + # Generate unique name instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" @@ -204,6 +222,40 @@ async def create_instance( # Find free port tool_port = find_free_port() + # Determine repo path based on clone mode + if data.clone_mode == "clone": + # Get SSH key for cloning + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="repository SSH key not found" + ) + + # Prepare SSH key for clone operation + ssh_key_path = None + try: + ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) + ssh_key_path = os.path.join(ssh_dir, "id_ed25519") + + # Clone repository + clone_path = clone_repository( + remote_url=repo.remote_url, + ssh_key_path=ssh_key_path, + instance_dir=instance_dir, + branch=data.branch or "main", + ) + repo_path = clone_path + except Exception as exc: + logger.exception("Failed to clone repository: %s", exc) + cleanup_ssh_key_files(instance_dir) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to clone repository: {exc}" + ) + else: + repo_path = repo.path + # Handle based on definition type if tool_type.definition_type == "dockerfile": # Build image from Dockerfile @@ -235,7 +287,7 @@ services: ports: - "{tool_port}:{tool_type.default_port}" volumes: - - {repo.path}:/workspace + - {repo_path}:/workspace restart: unless-stopped """ write_compose_file(instance_dir, compose_content) @@ -243,7 +295,7 @@ services: else: # Render compose template variables = { - "REPO_PATH": repo.path, + "REPO_PATH": repo_path, "INSTANCE_NAME": instance_name, "INSTANCE_ID": instance_name, "TOOL_NAME": instance_name, @@ -265,6 +317,8 @@ services: status="pending", compose_path=compose_path, port=tool_port, + clone_mode=data.clone_mode, + branch=data.branch if data.clone_mode == "clone" else None, ) session.add(instance) await session.commit() @@ -276,6 +330,8 @@ services: "display_name": instance.display_name, "tool_type_id": str(instance.tool_type_id), "status": instance.status, + "clone_mode": instance.clone_mode, + "branch": instance.branch, "created_at": instance.created_at.isoformat(), } except Exception as exc: @@ -338,6 +394,8 @@ async def list_instances( "status": i.status, "url": i.url, "port": i.port, + "clone_mode": i.clone_mode, + "branch": i.branch, "created_at": i.created_at.isoformat(), }) @@ -398,6 +456,8 @@ async def get_instance( "compose_path": instance.compose_path, "url": instance.url, "port": instance.port, + "clone_mode": instance.clone_mode, + "branch": instance.branch, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, "created_at": instance.created_at.isoformat(), @@ -514,6 +574,23 @@ async def start_instance( extra_volumes.extend(folder_volumes) logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) + # Mount SSH key for clone-mode instances + if instance.clone_mode == "clone": + repo = await session.get(GitRepository, instance.repository_id) + if repo and repo.ssh_key_id: + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key: + try: + ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) + extra_volumes.append({ + "source": ssh_dir, + "target": "/root/.ssh", + "type": "ro", + }) + logger.info("Mounted SSH key for clone-mode instance %s", instance.id) + except Exception as exc: + logger.error("Failed to prepare SSH key for instance %s: %s", instance.id, exc) + # Modify compose file if needed (port override, start command, working dir, volumes) if port_override or start_command or working_directory or extra_volumes: _modify_compose_file(instance.compose_path, port_override, start_command, working_directory, extra_volumes) @@ -889,6 +966,7 @@ async def delete_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, + force: bool = False, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: @@ -913,6 +991,23 @@ async def delete_instance( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) + # Check dirty state for clone-mode instances + if instance.clone_mode == "clone" and not force: + instance_dir = os.path.dirname(instance.compose_path) if instance.compose_path else None + if instance_dir: + clone_path = os.path.join(instance_dir, "repo-clone") + if os.path.exists(clone_path): + is_dirty, changed_files = check_dirty_state(clone_path) + if is_dirty: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Repository has uncommitted changes", + "changed_files": changed_files, + "force_required": True, + }, + ) + # Stop Cloudflare tunnel if exists if instance.tunnel_id: try: @@ -925,7 +1020,7 @@ async def delete_instance( if instance.compose_path and os.path.exists(instance.compose_path): execute_compose_command(instance.compose_path, "down") - # Remove instance directory + # Remove instance directory (includes clone and SSH keys) if instance.compose_path: instance_dir = os.path.dirname(instance.compose_path) if os.path.exists(instance_dir): diff --git a/apps/api/src/models/git_repository.py b/apps/api/src/models/git_repository.py index 5e8aa74..69dfd8a 100644 --- a/apps/api/src/models/git_repository.py +++ b/apps/api/src/models/git_repository.py @@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from src.models.project import Project + from src.models.ssh_key import SSHKey from src.models.user import User @@ -23,6 +24,10 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base): is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ssh_key_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("ssh_keys.id"), nullable=True + ) project: Mapped["Project"] = relationship(back_populates="repositories") owner: Mapped["User"] = relationship() + ssh_key: Mapped["SSHKey | None"] = relationship() diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index 162144e..8715558 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -65,6 +65,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): probe_result: Mapped[dict | None] = mapped_column( JSON, nullable=True ) + clone_mode: Mapped[str] = mapped_column( + String(20), nullable=False, default="mount" + ) + branch: Mapped[str | None] = mapped_column( + String(255), nullable=True, default="main" + ) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() diff --git a/apps/api/src/services/clone.py b/apps/api/src/services/clone.py new file mode 100644 index 0000000..743054b --- /dev/null +++ b/apps/api/src/services/clone.py @@ -0,0 +1,97 @@ +"""Clone service for repository cloning and dirty state checking.""" + +import logging +import os +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def clone_repository( + remote_url: str, + ssh_key_path: str | None, + instance_dir: str, + branch: str = "main", +) -> str: + """Clone a git repository into the instance directory. + + Args: + remote_url: Git remote URL (SSH or HTTPS) + ssh_key_path: Path to SSH private key for authentication (optional) + instance_dir: Path to instance directory + branch: Branch to clone (default: main) + + Returns: + Path to the cloned repository + """ + clone_path = Path(instance_dir) / "repo-clone" + clone_path.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + if ssh_key_path: + # Use SSH key for cloning + env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + cmd = [ + "git", + "clone", + "--branch", branch, + "--single-branch", + remote_url, + str(clone_path), + ] + + logger.info("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + env=env, + timeout=300, + ) + + if result.returncode != 0: + logger.error("Git clone failed: %s", result.stderr) + raise RuntimeError(f"Failed to clone repository: {result.stderr}") + + logger.info("Successfully cloned repository into %s", clone_path) + return str(clone_path) + + +def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]: + """Check for uncommitted changes in a cloned repository. + + Args: + clone_path: Path to the cloned repository + + Returns: + Tuple of (is_dirty, list_of_changed_files) + """ + result = subprocess.run( + ["git", "-C", clone_path, "status", "--short"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + logger.warning("Failed to check git status: %s", result.stderr) + return False, [] + + changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()] + is_dirty = len(changed_files) > 0 + + return is_dirty, changed_files + + +def remove_clone_directory(instance_dir: str) -> None: + """Remove the cloned repository from the instance directory. + + Args: + instance_dir: Path to instance directory + """ + clone_path = Path(instance_dir) / "repo-clone" + if clone_path.exists(): + import shutil + shutil.rmtree(clone_path) + logger.info("Removed clone directory: %s", clone_path) diff --git a/apps/api/src/services/ssh_keys.py b/apps/api/src/services/ssh_keys.py new file mode 100644 index 0000000..6f05bdc --- /dev/null +++ b/apps/api/src/services/ssh_keys.py @@ -0,0 +1,73 @@ +"""SSH key service utilities for preparing keys for container use.""" + +import os +from pathlib import Path + +from cryptography.fernet import Fernet + +from src.config import Settings + + +def _get_fernet() -> Fernet: + """Generate a valid Fernet key from the session secret.""" + import base64 + import hashlib + + settings = Settings() + key_bytes = hashlib.sha256(settings.session_secret.encode()).digest() + key = base64.urlsafe_b64encode(key_bytes) + return Fernet(key) + + +def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str: + """Decrypt and write SSH key files to instance directory for container mounting. + + Args: + instance_dir: Path to instance directory + ssh_key: SSHKey model instance with encrypted private key + + Returns: + Path to the .ssh directory + """ + ssh_dir = Path(instance_dir) / ".ssh" + ssh_dir.mkdir(parents=True, exist_ok=True) + + # Decrypt private key + fernet = _get_fernet() + private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() + + # Write private key with restricted permissions + private_key_path = ssh_dir / "id_ed25519" + private_key_path.write_text(private_key) + os.chmod(private_key_path, 0o600) + + # Write public key + public_key_path = ssh_dir / "id_ed25519.pub" + public_key_path.write_text(ssh_key.public_key) + os.chmod(public_key_path, 0o644) + + # Write SSH config + config_path = ssh_dir / "config" + config_content = """Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes +""" + config_path.write_text(config_content) + os.chmod(config_path, 0o644) + + return str(ssh_dir) + + +def cleanup_ssh_key_files(instance_dir: str) -> None: + """Remove temporary SSH key files from instance directory. + + Args: + instance_dir: Path to instance directory + """ + ssh_dir = Path(instance_dir) / ".ssh" + if ssh_dir.exists(): + for file_path in ssh_dir.iterdir(): + file_path.unlink() + ssh_dir.rmdir() diff --git a/apps/web/src/api/git_repositories.ts b/apps/web/src/api/git_repositories.ts index d2a3262..1b07c3e 100644 --- a/apps/web/src/api/git_repositories.ts +++ b/apps/web/src/api/git_repositories.ts @@ -8,6 +8,7 @@ export interface GitRepository { owner_id: string; is_mirror: boolean; remote_url: string | null; + ssh_key_id: string | null; last_push: string | null; created_at: string | null; } @@ -16,6 +17,7 @@ export interface GitRepositoryCreate { name: string; remote_url?: string; force_original_url?: boolean; + ssh_key_id?: string; } export interface URLParseResult { @@ -50,6 +52,18 @@ export async function deleteRepository(projectId: string, repoId: string): Promi await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); } +export async function updateRepositorySshKey( + projectId: string, + repoId: string, + sshKeyId: string | null +): Promise { + const response = await apiClient.patch( + `/projects/${projectId}/repositories/${repoId}/ssh-key`, + { ssh_key_id: sshKeyId } + ); + return response.data; +} + export interface CommitHistoryEntry { hash: string; short_hash: string; diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 05da0cc..a4e33a9 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -27,6 +27,8 @@ export interface Session { url: string | null; container_status?: string; probe_status?: string; + clone_mode?: string; + branch?: string | null; } export async function listInstances( @@ -43,13 +45,17 @@ export async function createInstance( projectId: string, repoId: string, toolTypeId: string, - displayName?: string + displayName?: string, + cloneMode?: string, + branch?: string ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, { tool_type_id: toolTypeId, display_name: displayName, + clone_mode: cloneMode || "mount", + branch: branch || undefined, } ); return response.data; @@ -91,10 +97,12 @@ export async function restartInstance( export async function deleteInstance( projectId: string, repoId: string, - instanceId: string + instanceId: string, + force?: boolean ): Promise { await apiClient.delete( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, + { params: { force } } ); } diff --git a/apps/web/src/components/repository-create-dialog.tsx b/apps/web/src/components/repository-create-dialog.tsx index dda7269..bf4cdc0 100644 --- a/apps/web/src/components/repository-create-dialog.tsx +++ b/apps/web/src/components/repository-create-dialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git_repositories"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { Icon } from "./icon"; type CreateMode = "clone" | "blank"; @@ -26,6 +27,8 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea status: UrlValidationStatus; result: URLParseResult | null; }>({ status: "idle", result: null }); + const [sshKeys, setSshKeys] = useState([]); + const [selectedSshKey, setSelectedSshKey] = useState(""); const debounceTimer = useRef | null>(null); useEffect(() => { @@ -35,6 +38,19 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea } }, [open]); + useEffect(() => { + if (!open) return; + const loadKeys = async () => { + try { + const data = await listSSHKeys(); + setSshKeys(data); + } catch { + // ignore + } + }; + void loadKeys(); + }, [open]); + useEffect(() => { if (!open) return; if (!useAdvancedUrl) { @@ -84,6 +100,7 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea setUseAdvancedUrl(false); setFormError(null); setUrlValidation({ status: "idle", result: null }); + setSelectedSshKey(""); }; const handleClose = () => { @@ -120,6 +137,9 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea } input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; } + if (selectedSshKey) { + input.ssh_key_id = selectedSshKey; + } } await createRepository(projectId, input); @@ -212,6 +232,20 @@ export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCrea placeholder="repo-name" /> +

SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git

+ )} + {urlValidation.status === "needs-parsing" && urlValidation.result && ( +
+ + This looks like a browser URL + +
+ Suggested: {urlValidation.result.base_url} + +
- - )} - {urlValidation.status === "invalid" && ( - - Invalid URL - - )} + )} + {urlValidation.status === "invalid" && ( + + Invalid URL + + )} + + - + )} {formError && (
diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index c58d49f..bf74bad 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -16,6 +16,7 @@ import { import { listToolTypes, type ToolType } from "../api/tool_types"; import { createInstance } from "../api/sessions"; import { getUserConfig, updateUserConfig } from "../api/settings"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; import { Icon } from "../components/icon"; type SessionsStatus = "loading" | "ready" | "error"; @@ -38,6 +39,13 @@ export const SessionsPage = () => { const [createStatus, setCreateStatus] = useState("idle"); const [createError, setCreateError] = useState(null); + const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); + const [branch, setBranch] = useState("main"); + const [sshKeys, setSshKeys] = useState([]); + + const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); + const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [stopConfirmId, setStopConfirmId] = useState(null); const [tunnelHealth, setTunnelHealth] = useState { void loadToolTypes(); }, []); + useEffect(() => { + const loadSshKeys = async () => { + try { + const data = await listSSHKeys(); + setSshKeys(data); + } catch { + // ignore + } + }; + void loadSshKeys(); + }, []); + // Poll health every 30 seconds for active instances useEffect(() => { const checkHealth = async () => { @@ -177,13 +197,23 @@ export const SessionsPage = () => { return; } + if (cloneMode === "clone") { + const repo = repositories.find((r) => r.id === selectedRepo); + if (!repo?.ssh_key_id) { + setCreateError("Repository must have an SSH key assigned for clone mode"); + return; + } + } + setCreateStatus("creating"); try { const instance = await createInstance( selectedProject, selectedRepo, selectedToolType, - displayName || undefined + displayName || undefined, + cloneMode, + cloneMode === "clone" ? branch : undefined ); // Auto-start the instance @@ -195,10 +225,16 @@ export const SessionsPage = () => { setSelectedRepo(""); setSelectedToolType(""); setDisplayName(""); + setCloneMode("mount"); + setBranch("main"); await loadSessions(); - } catch { + } catch (error) { setCreateStatus("error"); - setCreateError("Failed to create session"); + const axiosError = error as { response?: { data?: { detail?: string } } }; + const message = axiosError.response?.data?.detail; + setCreateError( + typeof message === "string" ? message : "Failed to create session" + ); } }; @@ -212,13 +248,25 @@ export const SessionsPage = () => { } }; - const handleDelete = async (sessionId: string, projectId: string, repoId: string) => { + const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => { try { - await deleteInstance(projectId, repoId, sessionId); + await deleteInstance(projectId, repoId, sessionId, force); setDeleteConfirmId(null); + setDirtyDeleteSession(null); + setDirtyDeleteFiles([]); // Remove from local state immediately setSessions((prev) => prev.filter((s) => s.id !== sessionId)); - } catch { + } catch (error) { + const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } }; + if (axiosError.response?.status === 409) { + const detail = axiosError.response.data?.detail; + if (detail?.changed_files) { + setDirtyDeleteSession(sessions.find((s) => s.id === sessionId) ?? null); + setDirtyDeleteFiles(detail.changed_files); + setDeleteConfirmId(null); + return; + } + } setDeleteConfirmId(null); } }; @@ -608,6 +656,70 @@ export const SessionsPage = () => {
+
+ + + {cloneMode === "clone" && ( + <> + + + {selectedRepo && ( +
+ {(() => { + const repo = repositories.find((r) => r.id === selectedRepo); + if (!repo) return null; + if (repo.ssh_key_id) { + const key = sshKeys.find((k) => k.id === repo.ssh_key_id); + return ( + + SSH key: {key?.name || "Assigned"} + + ); + } + return ( + + No SSH key assigned to this repository. Clone mode requires an SSH key. + + ); + })()} +
+ )} + + )} +
+