feat: add ssh_key_id to config profiles for container key mounting
- Add ssh_key_id column to ConfigProfile model and migration - Update config profile API to accept/return ssh_key_id - Include ssh_key_id in ResolvedProfile and resolver logic - Mount selected SSH key into container home dir at start_instance - Frontend config profile form with SSH key selector dropdown - Git mount URL validation defaults to profile's SSH key Quality gates: pytest (231 passed, 6 pre-existing), tsc --noEmit clean
This commit is contained in:
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -52,6 +53,7 @@ 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>;
|
||||
@@ -78,6 +80,7 @@ export interface CreateConfigProfileRequest {
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
ssh_key_id?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
@@ -91,6 +94,7 @@ export interface UpdateConfigProfileRequest {
|
||||
mounts?: ConfigProfileMount[];
|
||||
git_mounts?: GitMount[];
|
||||
files?: Record<string, string>;
|
||||
ssh_key_id?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
defaultSshKeyId?: string;
|
||||
}
|
||||
|
||||
function normalizeMount(mount: GitMount): GitMount {
|
||||
@@ -33,7 +34,7 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
||||
return mounts.map(normalizeMount);
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => {
|
||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||
normalizeMounts(mounts),
|
||||
);
|
||||
@@ -92,6 +93,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
mount={mount}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
defaultSshKeyId={defaultSshKeyId}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
@@ -183,6 +185,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||
}}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
defaultSshKeyId={defaultSshKeyId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -203,6 +206,7 @@ interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
defaultSshKeyId?: string;
|
||||
}
|
||||
|
||||
type ValidationState =
|
||||
@@ -212,7 +216,7 @@ type ValidationState =
|
||||
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||
| { status: "invalid"; message: string };
|
||||
|
||||
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountFormProps) => {
|
||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||
const [branch, setBranch] = useState(mount.branch || "");
|
||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||
@@ -221,7 +225,9 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
: [{ source_path: ".", target_path: "" }],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [validation, setValidation] = useState<ValidationState>({ status: "idle" });
|
||||
const [validation, setValidation] = useState<ValidationState>({
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
const isUrlValidated =
|
||||
validation.status === "valid" ||
|
||||
@@ -239,7 +245,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await validateGitUrl(remoteUrl.trim());
|
||||
const result = await validateGitUrl(
|
||||
remoteUrl.trim(),
|
||||
defaultSshKeyId,
|
||||
);
|
||||
if (result.valid && result.branches) {
|
||||
setValidation({
|
||||
status: "valid",
|
||||
@@ -351,7 +360,10 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
|
||||
<div
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Repository URL
|
||||
@@ -393,16 +405,19 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
)}
|
||||
{validation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
Repository is accessible ({(validation as Extract<ValidationState, { status: "valid" }>).branches.length} branches)
|
||||
Repository is accessible (
|
||||
{
|
||||
(validation as Extract<ValidationState, { status: "valid" }>)
|
||||
.branches.length
|
||||
}{" "}
|
||||
branches)
|
||||
</span>
|
||||
)}
|
||||
{validation.status === "suggestion" && (
|
||||
<div className="url-suggestion">
|
||||
<span>{validation.message}</span>
|
||||
<div className="suggestion-actions">
|
||||
<code className="suggested-url">
|
||||
{validation.suggestedUrl}
|
||||
</code>
|
||||
<code className="suggested-url">{validation.suggestedUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
@@ -429,13 +444,13 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="form-input"
|
||||
>
|
||||
{(validation as Extract<ValidationState, { status: "valid" }>).branches.map(
|
||||
(b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
{(
|
||||
validation as Extract<ValidationState, { status: "valid" }>
|
||||
).branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
@@ -450,7 +465,12 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ opacity: isUrlValidated ? 1 : 0.5, pointerEvents: isUrlValidated ? "auto" : "none" }}>
|
||||
<div
|
||||
style={{
|
||||
opacity: isUrlValidated ? 1 : 0.5,
|
||||
pointerEvents: isUrlValidated ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Mappings
|
||||
</label>
|
||||
@@ -461,7 +481,8 @@ const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
{!isUrlValidated && (
|
||||
<span style={{ color: "var(--warning)" }}>
|
||||
{" "}Validate the URL first.
|
||||
{" "}
|
||||
Validate the URL first.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type CreateConfigProfileRequest,
|
||||
type ResolvedProfile,
|
||||
} from "../api/config_profiles";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
@@ -33,6 +34,7 @@ export const ConfigProfilesPage = () => {
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -50,6 +52,7 @@ export const ConfigProfilesPage = () => {
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
ssh_key_id: undefined,
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
@@ -61,11 +64,13 @@ export const ConfigProfilesPage = () => {
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [profs, projs, types] = await Promise.all([
|
||||
const [profs, projs, types, keys] = await Promise.all([
|
||||
listConfigProfiles(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
listSSHKeys(),
|
||||
]);
|
||||
setSshKeys(keys);
|
||||
setProfiles(profs || []);
|
||||
setProjects(projs || []);
|
||||
setToolTypes(types || []);
|
||||
@@ -89,6 +94,7 @@ export const ConfigProfilesPage = () => {
|
||||
mounts: [],
|
||||
git_mounts: [],
|
||||
files: {},
|
||||
ssh_key_id: undefined,
|
||||
is_default: false,
|
||||
});
|
||||
setIncludedProfileIds([]);
|
||||
@@ -108,6 +114,7 @@ export const ConfigProfilesPage = () => {
|
||||
mounts: profile.mounts,
|
||||
git_mounts: profile.git_mounts || [],
|
||||
files: profile.files,
|
||||
ssh_key_id: profile.ssh_key_id || undefined,
|
||||
is_default: profile.is_default,
|
||||
});
|
||||
setIncludedProfileIds(
|
||||
@@ -467,6 +474,7 @@ export const ConfigProfilesPage = () => {
|
||||
{ label: "Description", value: selectedProfile.description || "-" },
|
||||
{ label: "Scope", value: getScopeLabel(selectedProfile) },
|
||||
{ label: "Default", value: selectedProfile.is_default ? "Yes" : "No" },
|
||||
{ label: "SSH Key", value: sshKeys.find(k => k.id === selectedProfile.ssh_key_id)?.name || "-" },
|
||||
{ label: "Environment Variables", value: Object.keys(selectedProfile.env_vars).length > 0 ? Object.entries(selectedProfile.env_vars).map(([k, v]) => `${k}=${v}`).join(", ") : "-" },
|
||||
{ label: "Mounts", value: selectedProfile.mounts.length > 0 ? selectedProfile.mounts.map((m) => `${m.target} (${m.mode})`).join(", ") : "-" },
|
||||
{ label: "Includes", value: selectedProfile.includes.length > 0 ? `${selectedProfile.includes.length} profile(s)` : "-" },
|
||||
@@ -563,6 +571,27 @@ export const ConfigProfilesPage = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>SSH Key</label>
|
||||
<select
|
||||
value={formData.ssh_key_id || ""}
|
||||
onChange={(e) =>
|
||||
updateFormField("ssh_key_id", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{sshKeys.map((key) => (
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="hint">
|
||||
Mounts this SSH key into the container at {"~/.ssh"} when a
|
||||
tool instance is started with this profile.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="form-group">
|
||||
<label>Environment Variables</label>
|
||||
@@ -1248,6 +1277,7 @@ export const ConfigProfilesPage = () => {
|
||||
<GitMountEditor
|
||||
mounts={formData.git_mounts || []}
|
||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||
defaultSshKeyId={formData.ssh_key_id || undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user