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
@@ -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"