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(
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"
)
@@ -252,9 +249,6 @@ 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"
)
@@ -382,7 +376,6 @@ 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": [
{
+50 -34
View File
@@ -435,6 +435,9 @@ class CreateInstanceRequest(BaseModel):
config_profile_id: str | None = Field(
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):
@@ -445,6 +448,9 @@ class StartInstanceRequest(BaseModel):
config_profile_id: str | None = Field(
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(
@@ -964,6 +970,7 @@ services:
if data.new_branch
else (data.branch if data.clone_mode == "clone" else None),
selected_config_profile_id=selected_profile_id,
ssh_key_ids=data.ssh_key_ids or None,
)
session.add(instance)
await session.commit()
@@ -1054,6 +1061,7 @@ async def list_instances(
"port": i.port,
"clone_mode": i.clone_mode,
"branch": i.branch,
"ssh_key_ids": i.ssh_key_ids or [],
"created_at": i.created_at.isoformat(),
}
)
@@ -1300,6 +1308,11 @@ async def start_instance(
instance.selected_config_profile_id = selected_profile_id
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):
raise HTTPException(
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"]
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,
@@ -1422,6 +1401,43 @@ async def start_instance(
"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 ──────────────────────────────────────
resolved_manifest = None
-5
View File
@@ -9,7 +9,6 @@ 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
@@ -43,15 +42,11 @@ 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",
+1
View File
@@ -59,6 +59,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
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()
repository: Mapped["GitRepository"] = relationship()
@@ -51,7 +51,6 @@ 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)
@@ -319,9 +318,6 @@ 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(
@@ -353,9 +349,6 @@ 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
@@ -571,5 +564,4 @@ 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,
}
-4
View File
@@ -12,7 +12,6 @@ export interface ConfigProfile {
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
ssh_key_id: string | null;
is_default: boolean;
includes: ConfigProfileInclude[];
created_at: string;
@@ -53,7 +52,6 @@ export interface ResolvedProfile {
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
ssh_key_id: string | null;
overrides: {
env_vars: Record<string, string>;
runtime_hints: Record<string, string>;
@@ -80,7 +78,6 @@ export interface CreateConfigProfileRequest {
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
ssh_key_id?: string;
is_default?: boolean;
}
@@ -94,7 +91,6 @@ export interface UpdateConfigProfileRequest {
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
ssh_key_id?: string;
is_default?: boolean;
}
+10 -5
View File
@@ -12,6 +12,7 @@ export interface ToolInstance {
url: string | null;
port: number | null;
selected_config_profile_id: string | null;
ssh_key_ids: string[];
created_at: string;
}
@@ -52,7 +53,8 @@ export async function createInstance(
cloneMode?: string,
branch?: string,
newBranch?: string,
configProfileId?: string
configProfileId?: string,
sshKeyIds?: string[]
): Promise<ToolInstance> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`,
@@ -63,6 +65,7 @@ export async function createInstance(
branch: branch || undefined,
new_branch: newBranch || undefined,
config_profile_id: configProfileId,
ssh_key_ids: sshKeyIds || [],
}
);
return response.data;
@@ -73,12 +76,13 @@ export async function startInstance(
repoId: string,
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
retries = 2
): Promise<{ status: string; url?: string }> {
try {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
{ config_profile_id: configProfileId }
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
);
return response.data;
} catch (error) {
@@ -86,7 +90,7 @@ export async function startInstance(
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
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;
}
@@ -108,12 +112,13 @@ export async function restartInstance(
repoId: string,
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
retries = 2
): Promise<{ status: string; url?: string }> {
try {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
{ config_profile_id: configProfileId }
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
);
return response.data;
} catch (error) {
@@ -121,7 +126,7 @@ export async function restartInstance(
const axiosError = error as AxiosError;
if (retries > 0 && !axiosError.response) {
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;
}
@@ -49,6 +49,7 @@ export const CreateSessionForm = ({
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
@@ -60,9 +61,8 @@ export const CreateSessionForm = ({
const [progress, setProgress] = useState("");
const [error, setError] = useState<string | null>(null);
// Load SSH keys when clone mode is shown
// Load SSH keys
useEffect(() => {
if (!showCloneMode) return;
const loadKeys = async () => {
try {
const keys = await listSSHKeys();
@@ -72,7 +72,7 @@ export const CreateSessionForm = ({
}
};
void loadKeys();
}, [showCloneMode]);
}, []);
// Load config profiles when tool type is selected
useEffect(() => {
@@ -166,7 +166,8 @@ export const CreateSessionForm = ({
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined,
selectedConfigProfile || undefined
selectedConfigProfile || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
);
setProgress("Starting container...");
@@ -182,7 +183,8 @@ export const CreateSessionForm = ({
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
setBranches([]);
setSelectedSshKeyIds([]);
setStatus("idle");
onSuccess?.(instance);
@@ -344,8 +346,54 @@ export const CreateSessionForm = ({
</label>
)}
{/* Step 5: Clone Mode & Branch */}
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
{/* Step 5: SSH Keys */}
{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">
<label className="form-field">
<div className="radio-group">
@@ -468,8 +516,8 @@ export const CreateSessionForm = ({
</div>
)}
{/* Step 6: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
{/* Step 7: Display Name */}
{hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
<label className="form-field">
<input
type="text"
+10 -10
View File
@@ -6,7 +6,6 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
onChange: (mounts: GitMount[]) => void;
defaultSshKeyId?: string;
}
function normalizeMount(mount: GitMount): GitMount {
@@ -34,7 +33,10 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount);
}
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => {
export const GitMountEditor = ({
mounts,
onChange,
}: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
normalizeMounts(mounts),
);
@@ -93,7 +95,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
defaultSshKeyId={defaultSshKeyId}
/>
) : (
<div>
@@ -185,7 +186,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
}}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
defaultSshKeyId={defaultSshKeyId}
/>
</div>
) : (
@@ -206,7 +206,6 @@ interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => void;
onCancel: () => void;
defaultSshKeyId?: string;
}
type ValidationState =
@@ -216,7 +215,11 @@ type ValidationState =
| { status: "suggestion"; suggestedUrl: string; 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 [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>(
@@ -245,10 +248,7 @@ const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountForm
return next;
});
try {
const result = await validateGitUrl(
remoteUrl.trim(),
defaultSshKeyId,
);
const result = await validateGitUrl(remoteUrl.trim());
if (result.valid && result.branches) {
setValidation({
status: "valid",
+255 -125
View File
@@ -12,6 +12,7 @@ import {
import type { ToolType } from "../api/tool_types";
import { CreateSessionForm } from "./create-session-form";
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { useEventContext } from "../state/events";
const API_BASE_URL =
@@ -47,6 +48,10 @@ export const InstanceList = ({
string | null
>(null);
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
string[]
>([]);
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
// Per-instance busy state for actions
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
@@ -105,8 +110,12 @@ export const InstanceList = ({
const loadConfigProfiles = useCallback(
async (toolTypeId: string) => {
try {
const profiles = await listConfigProfiles(projectId, toolTypeId);
const [profiles, keys] = await Promise.all([
listConfigProfiles(projectId, toolTypeId),
listSSHKeys(),
]);
setConfigProfiles(profiles);
setSshKeys(keys);
} catch {
// ignore
}
@@ -114,12 +123,23 @@ export const InstanceList = ({
[projectId],
);
const handleStart = async (instanceId: string, configProfileId?: string) => {
const handleStart = async (
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
) => {
setBusyInstanceId(instanceId);
try {
await startInstance(projectId, repoId, instanceId, configProfileId);
await startInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances();
} catch {
setError("Failed to start instance");
@@ -144,12 +164,20 @@ export const InstanceList = ({
const handleRestart = async (
instanceId: string,
configProfileId?: string,
sshKeyIds?: string[],
) => {
setBusyInstanceId(instanceId);
try {
await restartInstance(projectId, repoId, instanceId, configProfileId);
await restartInstance(
projectId,
repoId,
instanceId,
configProfileId,
sshKeyIds,
);
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
setSelectedSshKeyIdsForAction([]);
await loadInstances();
} catch {
setError("Failed to restart instance");
@@ -279,69 +307,120 @@ export const InstanceList = ({
)}
{instance.status !== "running" && (
<>
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() =>
void handleStart(
instance.id,
selectedProfileForAction || undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
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 || "",
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="play" size="sm" />
Start
</button>
)}
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</label>
))}
</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" && (
@@ -376,68 +455,119 @@ export const InstanceList = ({
<Icon name="stop" size="sm" />
</button>
)}
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
className="primary-button small"
onClick={() =>
void handleRestart(
instance.id,
selectedProfileForAction || undefined,
)
}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
Restart
</button>
<button
className="ghost-button small"
onClick={() => {
setProfileSelectInstanceId(null);
setSelectedProfileForAction("");
{profileSelectInstanceId === instance.id ? (
<div className="inline-profile-select">
<select
value={selectedProfileForAction}
onChange={(e) =>
setSelectedProfileForAction(e.target.value)
}
>
<option value="">Default (none)</option>
{configProfiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
marginTop: "0.25rem",
}}
>
{sshKeys.map((key) => (
<label
key={key.id}
className="checkbox-label"
style={{
fontSize: "0.75rem",
display: "flex",
alignItems: "center",
gap: "0.25rem",
}}
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 || "",
);
}}
type="button"
disabled={busyInstanceId === instance.id}
>
<Icon name="refresh" size="sm" />
</button>
)}
<input
type="checkbox"
checked={selectedSshKeyIdsForAction.includes(
key.id,
)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSshKeyIdsForAction(
(prev) => [...prev, key.id],
);
} else {
setSelectedSshKeyIdsForAction(
(prev) =>
prev.filter(
(id) =>
id !== key.id,
),
);
}
}}
/>
{key.name}
</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
File diff suppressed because it is too large Load Diff
@@ -1,26 +1,27 @@
name: ssh-key-mounting
status: implementing
status: implemented
priority: high
created_at: 2026-05-28
updated_at: 2026-05-28
updated_at: 2026-05-29
labels:
- feature
- ssh
- config-profiles
- instances
stories:
- title: Select SSH key in config profile
- title: Select SSH keys when creating/starting instances
description: |
Add ssh_key_id to ConfigProfile so users can select an SSH key
to mount into container home directory (~/.ssh) when starting
a tool instance with that profile.
Add ssh_key_ids to ToolInstance so users can select multiple SSH keys
from a list when creating or starting a tool instance. Selected keys
are mounted into the container user's home directory (~/.ssh).
acceptance_criteria:
- ConfigProfile model has nullable ssh_key_id column
- Config profile API accepts/returns ssh_key_id
- ResolvedProfile includes ssh_key_id
- start_instance mounts SSH key to {home_dir}/.ssh after applying profile
- Frontend config profile form has SSH key selector dropdown
- Git mount URL validation defaults to profile's SSH key
- ToolInstance model has nullable ssh_key_ids JSON column
- create_instance endpoint accepts ssh_key_ids list
- start_instance endpoint accepts ssh_key_ids override
- start_instance mounts all selected SSH keys to {home_dir}/.ssh
- Frontend CreateSessionForm shows multi-select SSH key checkboxes
- Frontend instance-list shows SSH key multi-select for start/restart
- SSH keys are validated (existence, user ownership) before mounting
tests:
- unit: test_config_profile_resolver.py (resolver includes ssh_key_id)
- unit: test_tool_instances_legacy.py (ssh key mount integration)
- unit: test_tool_instances_legacy.py (existing baseline)
- integration: manual verification of mount behavior
estimated_effort: small