From 57ff236f2d8840bc5e94d6d75a969bdcfb3b3a23 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 12:53:51 +0200 Subject: [PATCH] feat: add ssh_key_id to config profiles for container key mounting - Add ssh_key_id column to ConfigProfile model and migration - Update config profile API to accept/return ssh_key_id - Include ssh_key_id in ResolvedProfile and resolver logic - Mount selected SSH key into container home dir at start_instance - Frontend config profile form with SSH key selector dropdown - Git mount URL validation defaults to profile's SSH key Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean --- ...4dc9b_add_ssh_key_id_to_config_profiles.py | 32 +++++++++++ apps/api/src/api/config_profiles.py | 26 +++++++-- apps/api/src/api/tool_instances.py | 34 +++++++++++ apps/api/src/models/config_profile.py | 5 ++ .../src/services/config_profile_resolver.py | 8 +++ apps/api/src/services/ssh_keys.py | 5 +- apps/web/src/api/config_profiles.ts | 4 ++ apps/web/src/components/git-mount-editor.tsx | 57 +++++++++++++------ apps/web/src/pages/config-profiles.tsx | 32 ++++++++++- .../changes/ssh-key-mounting/.openspec.yaml | 26 +++++++++ openspec/explorations/ssh-key-mounting.md | 29 ++++++++++ 11 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 apps/api/alembic/versions/069d3da4dc9b_add_ssh_key_id_to_config_profiles.py create mode 100644 openspec/changes/ssh-key-mounting/.openspec.yaml create mode 100644 openspec/explorations/ssh-key-mounting.md diff --git a/apps/api/alembic/versions/069d3da4dc9b_add_ssh_key_id_to_config_profiles.py b/apps/api/alembic/versions/069d3da4dc9b_add_ssh_key_id_to_config_profiles.py new file mode 100644 index 0000000..ee0931c --- /dev/null +++ b/apps/api/alembic/versions/069d3da4dc9b_add_ssh_key_id_to_config_profiles.py @@ -0,0 +1,32 @@ +"""add_ssh_key_id_to_config_profiles + +Revision ID: 069d3da4dc9b +Revises: 2026_05_29_add_notifications_table +Create Date: 2026-05-29 12:30:16.580532 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "069d3da4dc9b" +down_revision = "2026_05_29_add_notifications_table" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "config_profiles", + sa.Column( + "ssh_key_id", + sa.Uuid(), + sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("config_profiles", "ssh_key_id") diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index e047c73..f971d66 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -188,6 +188,9 @@ class ConfigProfileCreate(BaseModel): git_mounts: list[GitMountItem] = Field( default_factory=list, description="Git repository mounts" ) + ssh_key_id: str | None = Field( + default=None, description="Optional SSH key ID to mount into containers" + ) is_default: bool = Field( default=False, description="Whether this is the default profile for its scope" ) @@ -249,6 +252,9 @@ class ConfigProfileUpdate(BaseModel): git_mounts: list[GitMountItem] | None = Field( default=None, description="Git repository mounts" ) + ssh_key_id: str | None = Field( + default=None, description="Optional SSH key ID to mount into containers" + ) is_default: bool | None = Field( default=None, description="Whether this is the default profile" ) @@ -376,6 +382,7 @@ def _profile_to_response( "mounts": profile.mounts or [], "git_mounts": profile.git_mounts or [], "files": profile.files or {}, + "ssh_key_id": str(profile.ssh_key_id) if profile.ssh_key_id else None, "is_default": profile.is_default, "includes": [ { @@ -842,7 +849,9 @@ async def resolve_default_profile( class ValidateGitUrlRequest(BaseModel): url: str = Field(description="Git remote URL to validate") - ssh_key_id: str | None = Field(default=None, description="Optional SSH key ID for private repos") + ssh_key_id: str | None = Field( + default=None, description="Optional SSH key ID for private repos" + ) class ValidateGitUrlResponse(BaseModel): @@ -953,11 +962,18 @@ async def validate_git_url( if result.returncode != 0: stderr = result.stderr.strip() - if "could not resolve" in stderr.lower() or "unable to access" in stderr.lower(): + if ( + "could not resolve" in stderr.lower() + or "unable to access" in stderr.lower() + ): error_msg = "Could not reach repository. Check the URL and network access." error_code = "UNREACHABLE" - elif "authentication" in stderr.lower() or "permission denied" in stderr.lower(): - error_msg = "Authentication failed. Provide an SSH key for private repositories." + elif ( + "authentication" in stderr.lower() or "permission denied" in stderr.lower() + ): + error_msg = ( + "Authentication failed. Provide an SSH key for private repositories." + ) error_code = "AUTH_FAILED" else: error_msg = f"Repository not accessible: {stderr[:200]}" @@ -979,7 +995,7 @@ async def validate_git_url( ref = parts[1] # refs/heads/branch-name if ref.startswith("refs/heads/"): - branch_name = ref[len("refs/heads/"):] + branch_name = ref[len("refs/heads/") :] branches.append(branch_name) if branch_name in ("main", "master"): default_branch = branch_name diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index c58b26f..88b672e 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1355,6 +1355,40 @@ async def start_instance( working_directory = profile_hints["working_directory"] if profile_hints.get("port_override"): port_override = profile_hints["port_override"] + # Mount SSH key from config profile into container home dir + if resolved.ssh_key_id is not None: + ssh_key = await session.get(SSHKey, resolved.ssh_key_id) + if ssh_key: + try: + ssh_dir = prepare_ssh_key_files( + instance_dir, ssh_key, subdir="mounts/ssh/.ssh" + ) + ssh_target = os.path.join(home_dir, ".ssh") + extra_volumes.append( + { + "source": ssh_dir, + "target": ssh_target, + "type": "ro", + } + ) + logger.debug( + "Mounted SSH key %s for instance %s to %s", + ssh_key.name, + instance.id, + ssh_target, + ) + except Exception as exc: + logger.error( + "Failed to prepare SSH key for instance %s: %s", + instance.id, + exc, + ) + else: + logger.warning( + "SSH key %s not found for config profile %s", + resolved.ssh_key_id, + resolved.profile_name, + ) logger.debug( "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", resolved.profile_name, diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index 04eae6f..aee8cd8 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -9,6 +9,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.tool_type import ToolType from src.models.user import User @@ -42,11 +43,15 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): git_mounts: Mapped[list] = mapped_column( JSON, default=list, nullable=False ) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...] + ssh_key_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True + ) is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) user: Mapped["User"] = relationship() project: Mapped["Project | None"] = relationship() tool_type: Mapped["ToolType | None"] = relationship() + ssh_key: Mapped["SSHKey | None"] = relationship() includes: Mapped[list["ConfigProfileInclude"]] = relationship( "ConfigProfileInclude", foreign_keys="ConfigProfileInclude.profile_id", diff --git a/apps/api/src/services/config_profile_resolver.py b/apps/api/src/services/config_profile_resolver.py index cdb9e5c..633a484 100644 --- a/apps/api/src/services/config_profile_resolver.py +++ b/apps/api/src/services/config_profile_resolver.py @@ -51,6 +51,7 @@ class ResolvedProfile: mounts: dict[str, ResolvedMount] = field(default_factory=dict) git_mounts: list[dict[str, Any]] = field(default_factory=list) files: dict[str, str] = field(default_factory=dict) + ssh_key_id: uuid.UUID | None = None env_overrides: dict[str, str] = field(default_factory=dict) hint_overrides: dict[str, str] = field(default_factory=dict) file_overrides: dict[str, str] = field(default_factory=dict) @@ -318,6 +319,9 @@ async def _resolve_profile_recursive( result.git_mounts = _merge_git_mounts( result.git_mounts, included.git_mounts, included.profile_name ) + # Later included profile's SSH key wins + if included.ssh_key_id is not None: + result.ssh_key_id = included.ssh_key_id # Apply the profile's own settings (selected profile overrides includes) result.env_vars = _merge_env_vars( @@ -349,6 +353,9 @@ async def _resolve_profile_recursive( profile.git_mounts or [], profile.name, ) + # Own SSH key overrides any inherited one + if profile.ssh_key_id is not None: + result.ssh_key_id = profile.ssh_key_id return result @@ -564,4 +571,5 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]: }, "git_mounts": resolved.git_mounts, "included_profiles": resolved.included_profiles, + "ssh_key_id": str(resolved.ssh_key_id) if resolved.ssh_key_id else None, } diff --git a/apps/api/src/services/ssh_keys.py b/apps/api/src/services/ssh_keys.py index 6f05bdc..69cf7c3 100644 --- a/apps/api/src/services/ssh_keys.py +++ b/apps/api/src/services/ssh_keys.py @@ -19,17 +19,18 @@ def _get_fernet() -> Fernet: return Fernet(key) -def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str: +def prepare_ssh_key_files(instance_dir: str, ssh_key, subdir: str = ".ssh") -> 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 + subdir: Subdirectory within instance_dir to write to (default: ".ssh") Returns: Path to the .ssh directory """ - ssh_dir = Path(instance_dir) / ".ssh" + ssh_dir = Path(instance_dir) / subdir ssh_dir.mkdir(parents=True, exist_ok=True) # Decrypt private key diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts index c8959b2..c8c65de 100644 --- a/apps/web/src/api/config_profiles.ts +++ b/apps/web/src/api/config_profiles.ts @@ -12,6 +12,7 @@ export interface ConfigProfile { mounts: ConfigProfileMount[]; git_mounts: GitMount[]; files: Record; + ssh_key_id: string | null; is_default: boolean; includes: ConfigProfileInclude[]; created_at: string; @@ -52,6 +53,7 @@ export interface ResolvedProfile { mounts: ResolvedMount[]; git_mounts: GitMount[]; files: Record; + ssh_key_id: string | null; overrides: { env_vars: Record; runtime_hints: Record; @@ -78,6 +80,7 @@ export interface CreateConfigProfileRequest { mounts?: ConfigProfileMount[]; git_mounts?: GitMount[]; files?: Record; + ssh_key_id?: string; is_default?: boolean; } @@ -91,6 +94,7 @@ export interface UpdateConfigProfileRequest { mounts?: ConfigProfileMount[]; git_mounts?: GitMount[]; files?: Record; + ssh_key_id?: string; is_default?: boolean; } diff --git a/apps/web/src/components/git-mount-editor.tsx b/apps/web/src/components/git-mount-editor.tsx index 85d67ff..893026f 100644 --- a/apps/web/src/components/git-mount-editor.tsx +++ b/apps/web/src/components/git-mount-editor.tsx @@ -6,6 +6,7 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles"; interface GitMountEditorProps { mounts: GitMount[]; onChange: (mounts: GitMount[]) => void; + defaultSshKeyId?: string; } function normalizeMount(mount: GitMount): GitMount { @@ -33,7 +34,7 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] { return mounts.map(normalizeMount); } -export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { +export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => { const [normalizedMounts, setNormalizedMounts] = useState(() => normalizeMounts(mounts), ); @@ -92,6 +93,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { mount={mount} onSave={(updated) => handleUpdate(index, updated)} onCancel={() => setEditingIndex(null)} + defaultSshKeyId={defaultSshKeyId} /> ) : (
@@ -183,6 +185,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { }} onSave={handleAdd} onCancel={() => setIsAdding(false)} + defaultSshKeyId={defaultSshKeyId} />
) : ( @@ -203,6 +206,7 @@ interface GitMountFormProps { mount: GitMount; onSave: (mount: GitMount) => void; onCancel: () => void; + defaultSshKeyId?: string; } type ValidationState = @@ -212,7 +216,7 @@ type ValidationState = | { status: "suggestion"; suggestedUrl: string; message: string } | { status: "invalid"; message: string }; -const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { +const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountFormProps) => { const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); const [branch, setBranch] = useState(mount.branch || ""); const [mappings, setMappings] = useState( @@ -221,7 +225,9 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { : [{ source_path: ".", target_path: "" }], ); const [errors, setErrors] = useState>({}); - const [validation, setValidation] = useState({ status: "idle" }); + const [validation, setValidation] = useState({ + status: "idle", + }); const isUrlValidated = validation.status === "valid" || @@ -239,7 +245,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { return next; }); try { - const result = await validateGitUrl(remoteUrl.trim()); + const result = await validateGitUrl( + remoteUrl.trim(), + defaultSshKeyId, + ); if (result.valid && result.branches) { setValidation({ status: "valid", @@ -351,7 +360,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => { return (
-
+