feat: instance-level SSH key selection for container mounting

- Revert mistaken ssh_key_id from ConfigProfile (model, API, resolver, frontend)
- Add ssh_key_ids JSON column to tool_instances via migration
- Update create_instance to accept and store ssh_key_ids
- Update start_instance to mount selected SSH keys to {home_dir}/.ssh
- Update list_instances to return ssh_key_ids
- Frontend CreateSessionForm: multi-select SSH key checkboxes
- Frontend instance-list: SSH key selector for start/restart actions
- Maintain separate SSH key dirs per key to avoid conflicts

Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean
This commit is contained in:
Alex Blank
2026-05-29 13:30:53 +02:00
parent cbd3436ff7
commit e9364fa70f
14 changed files with 2049 additions and 1524 deletions
@@ -0,0 +1,27 @@
"""add_ssh_key_ids_to_tool_instances
Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances
Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles
Create Date: 2026-05-29 12:46:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "2026_05_29_add_ssh_key_ids_to_tool_instances"
down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tool_instances",
sa.Column("ssh_key_ids", sa.JSON(), nullable=True),
)
def downgrade() -> None:
op.drop_column("tool_instances", "ssh_key_ids")
@@ -0,0 +1,32 @@
"""drop_ssh_key_id_from_config_profiles
Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles
Revises: 069d3da4dc9b
Create Date: 2026-05-29 12:45:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
down_revision = "069d3da4dc9b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("config_profiles", "ssh_key_id")
def downgrade() -> None:
op.add_column(
"config_profiles",
sa.Column(
"ssh_key_id",
sa.Uuid(),
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
nullable=True,
),
)
-7
View File
@@ -188,9 +188,6 @@ class ConfigProfileCreate(BaseModel):
git_mounts: list[GitMountItem] = Field( git_mounts: list[GitMountItem] = Field(
default_factory=list, description="Git repository mounts" 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( is_default: bool = Field(
default=False, description="Whether this is the default profile for its scope" default=False, description="Whether this is the default profile for its scope"
) )
@@ -252,9 +249,6 @@ class ConfigProfileUpdate(BaseModel):
git_mounts: list[GitMountItem] | None = Field( git_mounts: list[GitMountItem] | None = Field(
default=None, description="Git repository mounts" 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( is_default: bool | None = Field(
default=None, description="Whether this is the default profile" default=None, description="Whether this is the default profile"
) )
@@ -382,7 +376,6 @@ def _profile_to_response(
"mounts": profile.mounts or [], "mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [], "git_mounts": profile.git_mounts or [],
"files": profile.files 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, "is_default": profile.is_default,
"includes": [ "includes": [
{ {
+50 -34
View File
@@ -435,6 +435,9 @@ class CreateInstanceRequest(BaseModel):
config_profile_id: str | None = Field( config_profile_id: str | None = Field(
default=None, description="Optional config profile ID for launch" default=None, description="Optional config profile ID for launch"
) )
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
class StartInstanceRequest(BaseModel): class StartInstanceRequest(BaseModel):
@@ -445,6 +448,9 @@ class StartInstanceRequest(BaseModel):
config_profile_id: str | None = Field( config_profile_id: str | None = Field(
default=None, description="Config profile ID to apply, or null for none" default=None, description="Config profile ID to apply, or null for none"
) )
ssh_key_ids: list[str] = Field(
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
)
async def _validate_config_profile( async def _validate_config_profile(
@@ -964,6 +970,7 @@ services:
if data.new_branch if data.new_branch
else (data.branch if data.clone_mode == "clone" else None), else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id, selected_config_profile_id=selected_profile_id,
ssh_key_ids=data.ssh_key_ids or None,
) )
session.add(instance) session.add(instance)
await session.commit() await session.commit()
@@ -1054,6 +1061,7 @@ async def list_instances(
"port": i.port, "port": i.port,
"clone_mode": i.clone_mode, "clone_mode": i.clone_mode,
"branch": i.branch, "branch": i.branch,
"ssh_key_ids": i.ssh_key_ids or [],
"created_at": i.created_at.isoformat(), "created_at": i.created_at.isoformat(),
} }
) )
@@ -1300,6 +1308,11 @@ async def start_instance(
instance.selected_config_profile_id = selected_profile_id instance.selected_config_profile_id = selected_profile_id
await session.commit() await session.commit()
# Store SSH key selection if provided
if data and data.ssh_key_ids is not None:
instance.ssh_key_ids = data.ssh_key_ids or None
await session.commit()
if not instance.compose_path or not os.path.exists(instance.compose_path): if not instance.compose_path or not os.path.exists(instance.compose_path):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
@@ -1355,40 +1368,6 @@ async def start_instance(
working_directory = profile_hints["working_directory"] working_directory = profile_hints["working_directory"]
if profile_hints.get("port_override"): if profile_hints.get("port_override"):
port_override = profile_hints["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( logger.debug(
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
resolved.profile_name, resolved.profile_name,
@@ -1422,6 +1401,43 @@ async def start_instance(
"Wrote %d config files for instance %s", len(config_files), instance.id "Wrote %d config files for instance %s", len(config_files), instance.id
) )
# Mount selected SSH keys into container home dir
if instance.ssh_key_ids:
for key_id in instance.ssh_key_ids:
ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
if ssh_key and ssh_key.user_id == user_id:
try:
ssh_dir = prepare_ssh_key_files(
instance_dir, ssh_key, subdir=f"mounts/ssh/{key_id}/.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 %s for instance %s: %s",
key_id,
instance.id,
exc,
)
else:
logger.warning(
"SSH key %s not found or not authorized for user %s",
key_id,
user_id,
)
# ── MANIFEST-BASED FLOW ────────────────────────────────────── # ── MANIFEST-BASED FLOW ──────────────────────────────────────
resolved_manifest = None resolved_manifest = None
-5
View File
@@ -9,7 +9,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
@@ -43,15 +42,11 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
git_mounts: Mapped[list] = mapped_column( git_mounts: Mapped[list] = mapped_column(
JSON, default=list, nullable=False JSON, default=list, nullable=False
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...] ) # [{"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) is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship() user: Mapped["User"] = relationship()
project: Mapped["Project | None"] = relationship() project: Mapped["Project | None"] = relationship()
tool_type: Mapped["ToolType | None"] = relationship() tool_type: Mapped["ToolType | None"] = relationship()
ssh_key: Mapped["SSHKey | None"] = relationship()
includes: Mapped[list["ConfigProfileInclude"]] = relationship( includes: Mapped[list["ConfigProfileInclude"]] = relationship(
"ConfigProfileInclude", "ConfigProfileInclude",
foreign_keys="ConfigProfileInclude.profile_id", foreign_keys="ConfigProfileInclude.profile_id",
+1
View File
@@ -59,6 +59,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column( selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
) )
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
tool_type: Mapped["ToolType"] = relationship() tool_type: Mapped["ToolType"] = relationship()
repository: Mapped["GitRepository"] = relationship() repository: Mapped["GitRepository"] = relationship()
@@ -51,7 +51,6 @@ class ResolvedProfile:
mounts: dict[str, ResolvedMount] = field(default_factory=dict) mounts: dict[str, ResolvedMount] = field(default_factory=dict)
git_mounts: list[dict[str, Any]] = field(default_factory=list) git_mounts: list[dict[str, Any]] = field(default_factory=list)
files: dict[str, str] = field(default_factory=dict) files: dict[str, str] = field(default_factory=dict)
ssh_key_id: uuid.UUID | None = None
env_overrides: dict[str, str] = field(default_factory=dict) env_overrides: dict[str, str] = field(default_factory=dict)
hint_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) file_overrides: dict[str, str] = field(default_factory=dict)
@@ -319,9 +318,6 @@ async def _resolve_profile_recursive(
result.git_mounts = _merge_git_mounts( result.git_mounts = _merge_git_mounts(
result.git_mounts, included.git_mounts, included.profile_name 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) # Apply the profile's own settings (selected profile overrides includes)
result.env_vars = _merge_env_vars( result.env_vars = _merge_env_vars(
@@ -353,9 +349,6 @@ async def _resolve_profile_recursive(
profile.git_mounts or [], profile.git_mounts or [],
profile.name, 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 return result
@@ -571,5 +564,4 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
}, },
"git_mounts": resolved.git_mounts, "git_mounts": resolved.git_mounts,
"included_profiles": resolved.included_profiles, "included_profiles": resolved.included_profiles,
"ssh_key_id": str(resolved.ssh_key_id) if resolved.ssh_key_id else None,
} }
-4
View File
@@ -12,7 +12,6 @@ export interface ConfigProfile {
mounts: ConfigProfileMount[]; mounts: ConfigProfileMount[];
git_mounts: GitMount[]; git_mounts: GitMount[];
files: Record<string, string>; files: Record<string, string>;
ssh_key_id: string | null;
is_default: boolean; is_default: boolean;
includes: ConfigProfileInclude[]; includes: ConfigProfileInclude[];
created_at: string; created_at: string;
@@ -53,7 +52,6 @@ export interface ResolvedProfile {
mounts: ResolvedMount[]; mounts: ResolvedMount[];
git_mounts: GitMount[]; git_mounts: GitMount[];
files: Record<string, string>; files: Record<string, string>;
ssh_key_id: string | null;
overrides: { overrides: {
env_vars: Record<string, string>; env_vars: Record<string, string>;
runtime_hints: Record<string, string>; runtime_hints: Record<string, string>;
@@ -80,7 +78,6 @@ export interface CreateConfigProfileRequest {
mounts?: ConfigProfileMount[]; mounts?: ConfigProfileMount[];
git_mounts?: GitMount[]; git_mounts?: GitMount[];
files?: Record<string, string>; files?: Record<string, string>;
ssh_key_id?: string;
is_default?: boolean; is_default?: boolean;
} }
@@ -94,7 +91,6 @@ export interface UpdateConfigProfileRequest {
mounts?: ConfigProfileMount[]; mounts?: ConfigProfileMount[];
git_mounts?: GitMount[]; git_mounts?: GitMount[];
files?: Record<string, string>; files?: Record<string, string>;
ssh_key_id?: string;
is_default?: boolean; is_default?: boolean;
} }
+10 -5
View File
@@ -12,6 +12,7 @@ export interface ToolInstance {
url: string | null; url: string | null;
port: number | null; port: number | null;
selected_config_profile_id: string | null; selected_config_profile_id: string | null;
ssh_key_ids: string[];
created_at: string; created_at: string;
} }
@@ -52,7 +53,8 @@ export async function createInstance(
cloneMode?: string, cloneMode?: string,
branch?: string, branch?: string,
newBranch?: string, newBranch?: string,
configProfileId?: string configProfileId?: string,
sshKeyIds?: string[]
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
@@ -63,6 +65,7 @@ export async function createInstance(
branch: branch || undefined, branch: branch || undefined,
new_branch: newBranch || undefined, new_branch: newBranch || undefined,
config_profile_id: configProfileId, config_profile_id: configProfileId,
ssh_key_ids: sshKeyIds || [],
} }
); );
return response.data; return response.data;
@@ -73,12 +76,13 @@ export async function startInstance(
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[],
retries = 2 retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId } { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -86,7 +90,7 @@ export async function startInstance(
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
} }
throw error; throw error;
} }
@@ -108,12 +112,13 @@ export async function restartInstance(
repoId: string, repoId: string,
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[],
retries = 2 retries = 2
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
try { try {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId } { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -121,7 +126,7 @@ export async function restartInstance(
const axiosError = error as AxiosError; const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) { if (retries > 0 && !axiosError.response) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1); return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
} }
throw error; throw error;
} }
@@ -49,6 +49,7 @@ export const CreateSessionForm = ({
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]); const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]); const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [branches, setBranches] = useState<Branch[]>([]); const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [isLoadingBranches, setIsLoadingBranches] = useState(false);
@@ -60,9 +61,8 @@ export const CreateSessionForm = ({
const [progress, setProgress] = useState(""); const [progress, setProgress] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Load SSH keys when clone mode is shown // Load SSH keys
useEffect(() => { useEffect(() => {
if (!showCloneMode) return;
const loadKeys = async () => { const loadKeys = async () => {
try { try {
const keys = await listSSHKeys(); const keys = await listSSHKeys();
@@ -72,7 +72,7 @@ export const CreateSessionForm = ({
} }
}; };
void loadKeys(); void loadKeys();
}, [showCloneMode]); }, []);
// Load config profiles when tool type is selected // Load config profiles when tool type is selected
useEffect(() => { useEffect(() => {
@@ -166,7 +166,8 @@ export const CreateSessionForm = ({
showCloneMode && cloneMode === "clone" && isCreatingNewBranch showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName ? newBranchName
: undefined, : undefined,
selectedConfigProfile || undefined selectedConfigProfile || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
); );
setProgress("Starting container..."); setProgress("Starting container...");
@@ -182,7 +183,8 @@ export const CreateSessionForm = ({
setIsCreatingNewBranch(false); setIsCreatingNewBranch(false);
setNewBranchName(""); setNewBranchName("");
setBaseBranch(""); setBaseBranch("");
setBranches([]); setBranches([]);
setSelectedSshKeyIds([]);
setStatus("idle"); setStatus("idle");
onSuccess?.(instance); onSuccess?.(instance);
@@ -344,8 +346,54 @@ export const CreateSessionForm = ({
</label> </label>
)} )}
{/* Step 5: Clone Mode & Branch */} {/* Step 5: SSH Keys */}
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false, {hasToolType && renderStep("SSH Keys (optional)", 5, true, false,
<div className="form-field">
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{sshKeys.length === 0 && (
<span className="muted">No SSH keys configured.</span>
)}
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.375rem 0.75rem",
background: "var(--panel)",
borderRadius: "0.375rem",
border: "1px solid var(--border)",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={selectedSshKeyIds.includes(key.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIds((prev) => [...prev, key.id]);
} else {
setSelectedSshKeyIds((prev) =>
prev.filter((id) => id !== key.id)
);
}
}}
disabled={isSubmitting}
/>
{key.name}
</label>
))}
</div>
<div className="hint" style={{ marginTop: "0.5rem" }}>
Selected keys will be mounted into the container at ~/.ssh
</div>
</div>
)}
{/* Step 6: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
<div className="form-row"> <div className="form-row">
<label className="form-field"> <label className="form-field">
<div className="radio-group"> <div className="radio-group">
@@ -468,8 +516,8 @@ export const CreateSessionForm = ({
</div> </div>
)} )}
{/* Step 6: Display Name */} {/* Step 7: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName, {hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
<label className="form-field"> <label className="form-field">
<input <input
type="text" type="text"
+10 -10
View File
@@ -6,7 +6,6 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps { interface GitMountEditorProps {
mounts: GitMount[]; mounts: GitMount[];
onChange: (mounts: GitMount[]) => void; onChange: (mounts: GitMount[]) => void;
defaultSshKeyId?: string;
} }
function normalizeMount(mount: GitMount): GitMount { function normalizeMount(mount: GitMount): GitMount {
@@ -34,7 +33,10 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount); return mounts.map(normalizeMount);
} }
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => { export const GitMountEditor = ({
mounts,
onChange,
}: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
normalizeMounts(mounts), normalizeMounts(mounts),
); );
@@ -93,7 +95,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
mount={mount} mount={mount}
onSave={(updated) => handleUpdate(index, updated)} onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)} onCancel={() => setEditingIndex(null)}
defaultSshKeyId={defaultSshKeyId}
/> />
) : ( ) : (
<div> <div>
@@ -185,7 +186,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
}} }}
onSave={handleAdd} onSave={handleAdd}
onCancel={() => setIsAdding(false)} onCancel={() => setIsAdding(false)}
defaultSshKeyId={defaultSshKeyId}
/> />
</div> </div>
) : ( ) : (
@@ -206,7 +206,6 @@ interface GitMountFormProps {
mount: GitMount; mount: GitMount;
onSave: (mount: GitMount) => void; onSave: (mount: GitMount) => void;
onCancel: () => void; onCancel: () => void;
defaultSshKeyId?: string;
} }
type ValidationState = type ValidationState =
@@ -216,7 +215,11 @@ type ValidationState =
| { status: "suggestion"; suggestedUrl: string; message: string } | { status: "suggestion"; suggestedUrl: string; message: string }
| { status: "invalid"; message: string }; | { status: "invalid"; message: string };
const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountFormProps) => { const GitMountForm = ({
mount,
onSave,
onCancel,
}: GitMountFormProps) => {
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url); const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || ""); const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>( const [mappings, setMappings] = useState<GitMountMapping[]>(
@@ -245,10 +248,7 @@ const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountForm
return next; return next;
}); });
try { try {
const result = await validateGitUrl( const result = await validateGitUrl(remoteUrl.trim());
remoteUrl.trim(),
defaultSshKeyId,
);
if (result.valid && result.branches) { if (result.valid && result.branches) {
setValidation({ setValidation({
status: "valid", status: "valid",
+255 -125
View File
@@ -12,6 +12,7 @@ import {
import type { ToolType } from "../api/tool_types"; import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form"; import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles"; import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { useEventContext } from "../state/events"; import { useEventContext } from "../state/events";
const API_BASE_URL = const API_BASE_URL =
@@ -47,6 +48,10 @@ export const InstanceList = ({
string | null string | null
>(null); >(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState(""); const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
string[]
>([]);
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
// Per-instance busy state for actions // Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null); const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
@@ -105,8 +110,12 @@ export const InstanceList = ({
const loadConfigProfiles = useCallback( const loadConfigProfiles = useCallback(
async (toolTypeId: string) => { async (toolTypeId: string) => {
try { try {
const profiles = await listConfigProfiles(projectId, toolTypeId); const [profiles, keys] = await Promise.all([
listConfigProfiles(projectId, toolTypeId),
listSSHKeys(),
]);
setConfigProfiles(profiles); setConfigProfiles(profiles);
setSshKeys(keys);
} catch { } catch {
// ignore // ignore
} }
@@ -114,12 +123,23 @@ export const InstanceList = ({
[projectId], [projectId],
); );
const handleStart = async (instanceId: string, configProfileId?: string) => { const handleStart = async (
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
) => {
setBusyInstanceId(instanceId); setBusyInstanceId(instanceId);
try { try {
await startInstance(projectId, repoId, instanceId, configProfileId); await startInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null); setProfileSelectInstanceId(null);
setSelectedProfileForAction(""); setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to start instance"); setError("Failed to start instance");
@@ -144,12 +164,20 @@ export const InstanceList = ({
const handleRestart = async ( const handleRestart = async (
instanceId: string, instanceId: string,
configProfileId?: string, configProfileId?: string,
sshKeyIds?: string[],
) => { ) => {
setBusyInstanceId(instanceId); setBusyInstanceId(instanceId);
try { try {
await restartInstance(projectId, repoId, instanceId, configProfileId); await restartInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null); setProfileSelectInstanceId(null);
setSelectedProfileForAction(""); setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances(); await loadInstances();
} catch { } catch {
setError("Failed to restart instance"); setError("Failed to restart instance");
@@ -279,69 +307,120 @@ export const InstanceList = ({
)} )}
{instance.status !== "running" && ( {instance.status !== "running" && (
<> <>
{profileSelectInstanceId === instance.id ? ( {profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select"> <div className="inline-profile-select">
<select <select
value={selectedProfileForAction} value={selectedProfileForAction}
onChange={(e) => onChange={(e) =>
setSelectedProfileForAction(e.target.value) setSelectedProfileForAction(e.target.value)
} }
> >
<option value="">Default (none)</option> <option value="">Default (none)</option>
{configProfiles.map((p) => ( {configProfiles.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
</option> </option>
))} ))}
</select> </select>
<button <div
className="primary-button small" style={{
onClick={() => display: "flex",
void handleStart( flexWrap: "wrap",
instance.id, gap: "0.25rem",
selectedProfileForAction || undefined, marginTop: "0.25rem",
) }}
} >
type="button" {sshKeys.map((key) => (
disabled={busyInstanceId === instance.id} <label
> key={key.id}
<Icon name="play" size="sm" /> className="checkbox-label"
Start style={{
</button> fontSize: "0.75rem",
<button display: "flex",
className="ghost-button small" alignItems: "center",
onClick={() => { gap: "0.25rem",
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}} }}
type="button"
disabled={busyInstanceId === instance.id}
> >
Cancel <input
</button> type="checkbox"
</div> checked={selectedSshKeyIdsForAction.includes(
) : ( key.id,
<button )}
className="secondary-button small" onChange={(e) => {
onClick={() => { if (e.target.checked) {
const toolType = toolTypes.find( setSelectedSshKeyIdsForAction(
(t) => t.id === instance.tool_type_id, (prev) => [...prev, key.id],
); );
if (toolType) { } else {
void loadConfigProfiles(toolType.id); setSelectedSshKeyIdsForAction(
} (prev) =>
setProfileSelectInstanceId(instance.id); prev.filter(
setSelectedProfileForAction( (id) =>
instance.selected_config_profile_id || "", id !== key.id,
); ),
}} );
type="button" }
disabled={busyInstanceId === instance.id} }}
> />
<Icon name="play" size="sm" /> {key.name}
Start </label>
</button> ))}
)} </div>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
</> </>
)} )}
{instance.status === "running" && ( {instance.status === "running" && (
@@ -376,68 +455,119 @@ export const InstanceList = ({
<Icon name="stop" size="sm" /> <Icon name="stop" size="sm" />
</button> </button>
)} )}
{profileSelectInstanceId === instance.id ? ( {profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select"> <div className="inline-profile-select">
<select <select
value={selectedProfileForAction} value={selectedProfileForAction}
onChange={(e) => onChange={(e) =>
setSelectedProfileForAction(e.target.value) setSelectedProfileForAction(e.target.value)
} }
> >
<option value="">Default (none)</option> <option value="">Default (none)</option>
{configProfiles.map((p) => ( {configProfiles.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
</option> </option>
))} ))}
</select> </select>
<button <div
className="primary-button small" style={{
onClick={() => display: "flex",
void handleRestart( flexWrap: "wrap",
instance.id, gap: "0.25rem",
selectedProfileForAction || undefined, marginTop: "0.25rem",
) }}
} >
type="button" {sshKeys.map((key) => (
disabled={busyInstanceId === instance.id} <label
> key={key.id}
<Icon name="refresh" size="sm" /> className="checkbox-label"
Restart style={{
</button> fontSize: "0.75rem",
<button display: "flex",
className="ghost-button small" alignItems: "center",
onClick={() => { gap: "0.25rem",
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
}} }}
type="button"
disabled={busyInstanceId === instance.id}
> >
Cancel <input
</button> type="checkbox"
</div> checked={selectedSshKeyIdsForAction.includes(
) : ( key.id,
<button )}
className="ghost-button small" onChange={(e) => {
onClick={() => { if (e.target.checked) {
const toolType = toolTypes.find( setSelectedSshKeyIdsForAction(
(t) => t.id === instance.tool_type_id, (prev) => [...prev, key.id],
); );
if (toolType) { } else {
void loadConfigProfiles(toolType.id); setSelectedSshKeyIdsForAction(
} (prev) =>
setProfileSelectInstanceId(instance.id); prev.filter(
setSelectedProfileForAction( (id) =>
instance.selected_config_profile_id || "", id !== key.id,
); ),
}} );
type="button" }
disabled={busyInstanceId === instance.id} }}
> />
<Icon name="refresh" size="sm" /> {key.name}
</button> </label>
)} ))}
</div>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
selectedSshKeyIdsForAction.length > 0
? selectedSshKeyIdsForAction
: undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => {
const toolType = toolTypes.find(
(t) => t.id === instance.tool_type_id,
);
if (toolType) {
void loadConfigProfiles(toolType.id);
}
setProfileSelectInstanceId(instance.id);
setSelectedProfileForAction(
instance.selected_config_profile_id || "",
);
setSelectedSshKeyIdsForAction(
instance.ssh_key_ids || [],
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
</> </>
)} )}
<button <button
File diff suppressed because it is too large Load Diff
@@ -1,26 +1,27 @@
name: ssh-key-mounting name: ssh-key-mounting
status: implementing status: implemented
priority: high priority: high
created_at: 2026-05-28 created_at: 2026-05-28
updated_at: 2026-05-28 updated_at: 2026-05-29
labels: labels:
- feature - feature
- ssh - ssh
- config-profiles - instances
stories: stories:
- title: Select SSH key in config profile - title: Select SSH keys when creating/starting instances
description: | description: |
Add ssh_key_id to ConfigProfile so users can select an SSH key Add ssh_key_ids to ToolInstance so users can select multiple SSH keys
to mount into container home directory (~/.ssh) when starting from a list when creating or starting a tool instance. Selected keys
a tool instance with that profile. are mounted into the container user's home directory (~/.ssh).
acceptance_criteria: acceptance_criteria:
- ConfigProfile model has nullable ssh_key_id column - ToolInstance model has nullable ssh_key_ids JSON column
- Config profile API accepts/returns ssh_key_id - create_instance endpoint accepts ssh_key_ids list
- ResolvedProfile includes ssh_key_id - start_instance endpoint accepts ssh_key_ids override
- start_instance mounts SSH key to {home_dir}/.ssh after applying profile - start_instance mounts all selected SSH keys to {home_dir}/.ssh
- Frontend config profile form has SSH key selector dropdown - Frontend CreateSessionForm shows multi-select SSH key checkboxes
- Git mount URL validation defaults to profile's SSH key - Frontend instance-list shows SSH key multi-select for start/restart
- SSH keys are validated (existence, user ownership) before mounting
tests: tests:
- unit: test_config_profile_resolver.py (resolver includes ssh_key_id) - unit: test_tool_instances_legacy.py (existing baseline)
- unit: test_tool_instances_legacy.py (ssh key mount integration) - integration: manual verification of mount behavior
estimated_effort: small estimated_effort: small