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:
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1591
-1302
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
|
||||
|
||||
Reference in New Issue
Block a user