2a342927e7
manager_launch: trusted exec-config keys override req.Metadata.
buildLaunchMetadata applied executor config only when the matching
key was missing in req.Metadata. A request that carried metadata.
ssh_host_fingerprint (or ssh_host, ssh_identity_*) could pivot the
launch to a different target and bypass the pinned fingerprint.
Pin a list of connection-routing keys (ssh_host, ssh_port, ssh_user,
ssh_host_fingerprint, ssh_identity_source, ssh_identity_file,
ssh_proxy_jump) and have the executor config always win for those.
Cubic / CodeRabbit P1.
executor_ssh.CreateInstance: reuse the resumed session state.
ResumeRemoteInstance attaches the recovered ssh.Client + forwarder
to r.sessions[InstanceID] and refreshes metadata, then the lifecycle
manager unconditionally called CreateInstance, which dialed a fresh
client and started a second remote agentctl on top of the live one
— leaking the resumed state. CreateInstance now early-returns from
the existing r.sessions entry via buildResumedInstance. Cubic P1.
executor_ssh.Close: actually release tracked sessions.
After dropping the SSHConnPool, Close was a no-op. A hard kandev
exit (no per-session StopInstance) left forwarders and SSH clients
open. Iterate r.sessions and Close the forwarder + client for each.
Cubic P2.
buildAuthMethods: close the ssh-agent unix socket after handshake.
Each agent-authenticated dial opened net.Dial("unix", $SSH_AUTH_SOCK)
and never closed it; only PublicKeysCallback held the agent.Client
reference and that callback is consulted exactly once at handshake.
Return a cleanup func from buildAuthMethods + buildClientConfig and
defer it from dialSSH / dialViaJump so the socket releases as soon
as key exchange completes. Greptile P1 security.
waitAgentctlHealthy: probe HTTP /health, not just the TCP listener.
The local port is owned by the SSH forwarder, which Accepts before
it Dials the remote direct-tcpip channel — a TCP connect can succeed
before the channel actually establishes. Issue a real GET /health
and require a 2xx so a broken tunnel surfaces here rather than at
the first agent operation. Cubic P1.
ssh-connection-card: also gate the trust + save on in-flight tests.
Editing a connection field during a pending test silently kept
resultStale=false because prev.result was still null; when the test
returned, the new fingerprint was bound to the old form values but
the gate let the user trust + save anyway. Treat in-flight tests as
prior tests for staleness purposes, and preserve resultStale across
the test's success setState. Cubic P2.
607 lines
17 KiB
TypeScript
607 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Label } from "@kandev/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
|
import {
|
|
IconCheck,
|
|
IconLoader2,
|
|
IconShieldLock,
|
|
IconTerminal2,
|
|
IconTestPipe,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { testSSHConnection } from "@/lib/api/domains/ssh-api";
|
|
import { FingerprintTrustBlock } from "@/components/settings/ssh-fingerprint-trust-block";
|
|
import type {
|
|
SSHIdentitySource,
|
|
SSHTestRequest,
|
|
SSHTestResult,
|
|
SSHTestStep,
|
|
} from "@/lib/types/http-ssh";
|
|
|
|
// SSHExecutorConfig is the shape we persist into executor.Config on save.
|
|
// The host_fingerprint is set only after a successful Test Connection has
|
|
// completed and the user has ticked "Trust this host".
|
|
export interface SSHExecutorConfig {
|
|
name: string;
|
|
host_alias?: string;
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
identity_source: SSHIdentitySource;
|
|
identity_file?: string;
|
|
proxy_jump?: string;
|
|
host_fingerprint?: string;
|
|
}
|
|
|
|
export interface SSHConnectionCardProps {
|
|
initial?: Partial<SSHExecutorConfig>;
|
|
// Called when the user clicks Save after a successful test+trust. The
|
|
// returned config carries the freshly pinned fingerprint.
|
|
onSave: (config: SSHExecutorConfig) => Promise<void> | void;
|
|
// Existing running sessions for this executor. Triggers the
|
|
// "this won't affect existing sessions" warning on save.
|
|
runningSessionCount?: number;
|
|
}
|
|
|
|
interface SSHConnectionState {
|
|
form: SSHExecutorConfig;
|
|
testing: boolean;
|
|
saving: boolean;
|
|
result: SSHTestResult | null;
|
|
// resultStale flips true when the user edits a connection-affecting field
|
|
// after a successful test, so the prior fingerprint cannot be trusted for
|
|
// the current form. The result stays visible (the trust-gate spec expects
|
|
// the checkbox to render) but trust + save are gated off until the user
|
|
// re-runs Test Connection.
|
|
resultStale: boolean;
|
|
trust: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
// Fields whose value, if changed, could route the next connection to a
|
|
// different machine — editing any of them invalidates the current trust tick
|
|
// so the user must re-test against the new target.
|
|
const CONNECTION_FIELDS = new Set<keyof SSHExecutorConfig>([
|
|
"host_alias",
|
|
"host",
|
|
"port",
|
|
"user",
|
|
"identity_source",
|
|
"identity_file",
|
|
"proxy_jump",
|
|
]);
|
|
|
|
const SSH_FORM_DEFAULTS: SSHExecutorConfig = {
|
|
name: "",
|
|
host_alias: "",
|
|
host: "",
|
|
port: 22,
|
|
user: "",
|
|
identity_source: "agent",
|
|
identity_file: "",
|
|
proxy_jump: "",
|
|
host_fingerprint: undefined,
|
|
};
|
|
|
|
function initialState(initial?: Partial<SSHExecutorConfig>): SSHConnectionState {
|
|
return {
|
|
form: { ...SSH_FORM_DEFAULTS, ...(initial ?? {}) },
|
|
testing: false,
|
|
saving: false,
|
|
result: null,
|
|
resultStale: false,
|
|
trust: false,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function useSSHConnection(props: SSHConnectionCardProps) {
|
|
const [state, setState] = useState<SSHConnectionState>(() => initialState(props.initial));
|
|
const { form, testing, saving, result, resultStale, trust, error } = state;
|
|
|
|
const update = useCallback(
|
|
<K extends keyof SSHExecutorConfig>(key: K, value: SSHExecutorConfig[K]) => {
|
|
setState((prev) => {
|
|
const isConnectionField = CONNECTION_FIELDS.has(key);
|
|
// A connection edit invalidates the prior result. Mark stale when
|
|
// a test result is already on screen OR a test is mid-flight — in
|
|
// the latter case handleTest will see the staleness on completion
|
|
// and refuse to clear it, so a fingerprint returned for the old
|
|
// form can't be trusted against the new one.
|
|
const staleAfter = isConnectionField
|
|
? prev.result !== null || prev.testing || prev.resultStale
|
|
: prev.resultStale;
|
|
return {
|
|
...prev,
|
|
form: { ...prev.form, [key]: value },
|
|
resultStale: staleAfter,
|
|
trust: isConnectionField ? false : prev.trust,
|
|
error: null,
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const setTrust = useCallback((v: boolean) => setState((prev) => ({ ...prev, trust: v })), []);
|
|
|
|
const canTest = useMemo(() => {
|
|
if (testing) return false;
|
|
if (form.name.trim() === "") return false;
|
|
if ((form.host ?? "").trim() === "" && (form.host_alias ?? "").trim() === "") return false;
|
|
return true;
|
|
}, [form, testing]);
|
|
|
|
const handleTest = useCallback(async () => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
testing: true,
|
|
result: null,
|
|
resultStale: false,
|
|
error: null,
|
|
}));
|
|
try {
|
|
const req: SSHTestRequest = {
|
|
name: form.name,
|
|
host_alias: form.host_alias || undefined,
|
|
host: form.host || undefined,
|
|
port: form.port || undefined,
|
|
user: form.user || undefined,
|
|
identity_source: form.identity_source,
|
|
identity_file: form.identity_file || undefined,
|
|
proxy_jump: form.proxy_jump || undefined,
|
|
};
|
|
const res = await testSSHConnection(req);
|
|
setState((prev) => ({
|
|
...prev,
|
|
result: res,
|
|
// Preserve resultStale if the user edited a connection field while
|
|
// the test was in flight — the returned fingerprint is bound to the
|
|
// old form and must not be trusted against the new target.
|
|
resultStale: prev.resultStale,
|
|
testing: false,
|
|
}));
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : "Failed to reach backend";
|
|
setState((prev) => ({ ...prev, error: msg, testing: false }));
|
|
}
|
|
}, [form]);
|
|
|
|
const canSave = !!result?.success && !!result.fingerprint && trust && !resultStale && !saving;
|
|
|
|
const confirmRunningSessions = useCallback(
|
|
() =>
|
|
props.runningSessionCount
|
|
? window.confirm(
|
|
`This executor has ${props.runningSessionCount} running session(s). ` +
|
|
`They will keep running on the current host. Only new sessions started ` +
|
|
`after save will use the updated config. Continue?`,
|
|
)
|
|
: true,
|
|
[props.runningSessionCount],
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!canSave || !result?.fingerprint) return;
|
|
if (!confirmRunningSessions()) return;
|
|
setState((prev) => ({ ...prev, saving: true, error: null }));
|
|
try {
|
|
await props.onSave({ ...form, host_fingerprint: result.fingerprint });
|
|
setState((prev) => ({ ...prev, saving: false }));
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : "Failed to save executor";
|
|
setState((prev) => ({ ...prev, saving: false, error: msg }));
|
|
}
|
|
}, [canSave, confirmRunningSessions, form, props, result]);
|
|
|
|
return {
|
|
form,
|
|
testing,
|
|
saving,
|
|
result,
|
|
resultStale,
|
|
trust,
|
|
error,
|
|
canTest,
|
|
canSave,
|
|
update,
|
|
setTrust,
|
|
handleTest,
|
|
handleSave,
|
|
};
|
|
}
|
|
|
|
export function SSHConnectionCard(props: SSHConnectionCardProps) {
|
|
const c = useSSHConnection(props);
|
|
return (
|
|
<Card data-testid="ssh-connection-card">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<IconTerminal2 className="h-5 w-5" />
|
|
Connection
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Run an agent on any Linux box you can reach over SSH. linux/amd64 only in v1.
|
|
</CardDescription>
|
|
</div>
|
|
<ConnectionBadge fingerprint={c.form.host_fingerprint} />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<SSHConnectionForm form={c.form} onChange={c.update} />
|
|
{c.form.host_fingerprint && <PinnedFingerprintRow fingerprint={c.form.host_fingerprint} />}
|
|
<SSHConnectionActions
|
|
testing={c.testing}
|
|
saving={c.saving}
|
|
canTest={c.canTest}
|
|
canSave={c.canSave}
|
|
onTest={c.handleTest}
|
|
onSave={c.handleSave}
|
|
/>
|
|
{c.error && (
|
|
<p data-testid="ssh-error" className="text-sm text-red-600">
|
|
{c.error}
|
|
</p>
|
|
)}
|
|
{c.result && (
|
|
<TestResultDisplay
|
|
result={c.result}
|
|
trust={c.trust}
|
|
resultStale={c.resultStale}
|
|
onTrustChange={c.setTrust}
|
|
currentlyPinned={c.form.host_fingerprint}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
type FieldOnChange = <K extends keyof SSHExecutorConfig>(
|
|
key: K,
|
|
value: SSHExecutorConfig[K],
|
|
) => void;
|
|
|
|
function SSHConnectionForm({
|
|
form,
|
|
onChange,
|
|
}: {
|
|
form: SSHExecutorConfig;
|
|
onChange: FieldOnChange;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<TextField
|
|
id="ssh-name"
|
|
testId="ssh-input-name"
|
|
label="Name"
|
|
placeholder="My VPS"
|
|
value={form.name}
|
|
onChange={(v) => onChange("name", v)}
|
|
/>
|
|
<TextField
|
|
id="ssh-host-alias"
|
|
testId="ssh-input-host-alias"
|
|
label="Host alias from ~/.ssh/config (optional)"
|
|
hint="If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config."
|
|
placeholder="prod"
|
|
value={form.host_alias ?? ""}
|
|
onChange={(v) => onChange("host_alias", v)}
|
|
/>
|
|
<TextField
|
|
id="ssh-host"
|
|
testId="ssh-input-host"
|
|
label="Host"
|
|
placeholder="dev.example.com"
|
|
value={form.host ?? ""}
|
|
onChange={(v) => onChange("host", v)}
|
|
/>
|
|
<TextField
|
|
id="ssh-port"
|
|
testId="ssh-input-port"
|
|
label="Port"
|
|
type="number"
|
|
placeholder="22"
|
|
value={String(form.port ?? 22)}
|
|
onChange={(v) => onChange("port", parseInt(v, 10) || 22)}
|
|
/>
|
|
<TextField
|
|
id="ssh-user"
|
|
testId="ssh-input-user"
|
|
label="User"
|
|
placeholder="ubuntu"
|
|
value={form.user ?? ""}
|
|
onChange={(v) => onChange("user", v)}
|
|
/>
|
|
<IdentitySourceField
|
|
value={form.identity_source}
|
|
onChange={(v) => onChange("identity_source", v)}
|
|
/>
|
|
{form.identity_source === "file" && (
|
|
<TextField
|
|
id="ssh-identity-file"
|
|
testId="ssh-input-identity-file"
|
|
label="Identity file path"
|
|
hint="Passphrase-protected keys must be loaded into ssh-agent first."
|
|
placeholder="~/.ssh/id_ed25519"
|
|
value={form.identity_file ?? ""}
|
|
onChange={(v) => onChange("identity_file", v)}
|
|
/>
|
|
)}
|
|
<TextField
|
|
id="ssh-proxy-jump"
|
|
testId="ssh-input-proxy-jump"
|
|
label="ProxyJump (optional)"
|
|
hint="Single bastion hop. Chained jumps unsupported in v1."
|
|
placeholder="bastion.example.com"
|
|
value={form.proxy_jump ?? ""}
|
|
onChange={(v) => onChange("proxy_jump", v)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextField({
|
|
id,
|
|
testId,
|
|
label,
|
|
hint,
|
|
placeholder,
|
|
type,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
id: string;
|
|
testId: string;
|
|
label: string;
|
|
hint?: string;
|
|
placeholder?: string;
|
|
type?: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<FieldShell id={id} label={label} hint={hint}>
|
|
<Input
|
|
id={id}
|
|
data-testid={testId}
|
|
type={type}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
</FieldShell>
|
|
);
|
|
}
|
|
|
|
function IdentitySourceField({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: SSHIdentitySource;
|
|
onChange: (v: SSHIdentitySource) => void;
|
|
}) {
|
|
return (
|
|
<FieldShell id="ssh-identity-source" label="Identity source">
|
|
<Select value={value} onValueChange={(v) => onChange(v as SSHIdentitySource)}>
|
|
<SelectTrigger id="ssh-identity-source" data-testid="ssh-input-identity-source">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="agent" data-testid="ssh-input-identity-source-agent">
|
|
ssh-agent (SSH_AUTH_SOCK)
|
|
</SelectItem>
|
|
<SelectItem value="file" data-testid="ssh-input-identity-source-file">
|
|
Identity file (private key path)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldShell>
|
|
);
|
|
}
|
|
|
|
function FieldShell({
|
|
id,
|
|
label,
|
|
hint,
|
|
children,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
|
|
return (
|
|
<div
|
|
data-testid="ssh-fingerprint-pinned"
|
|
className="rounded-md border bg-muted/40 px-3 py-2 text-xs flex items-center gap-2"
|
|
>
|
|
<IconShieldLock className="h-4 w-4 shrink-0" />
|
|
<span className="text-muted-foreground">
|
|
Pinned fingerprint:{" "}
|
|
<code data-testid="ssh-fingerprint-pinned-value" className="font-mono">
|
|
{fingerprint}
|
|
</code>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SSHConnectionActions({
|
|
testing,
|
|
saving,
|
|
canTest,
|
|
canSave,
|
|
onTest,
|
|
onSave,
|
|
}: {
|
|
testing: boolean;
|
|
saving: boolean;
|
|
canTest: boolean;
|
|
canSave: boolean;
|
|
onTest: () => void;
|
|
onSave: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onTest}
|
|
disabled={!canTest}
|
|
data-testid="ssh-test-button"
|
|
className="cursor-pointer"
|
|
>
|
|
{testing ? (
|
|
<IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<IconTestPipe className="mr-1.5 h-4 w-4" />
|
|
)}
|
|
Test connection
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={onSave}
|
|
disabled={!canSave}
|
|
data-testid="ssh-save-button"
|
|
className="cursor-pointer"
|
|
>
|
|
{saving ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
|
|
if (!fingerprint) {
|
|
return (
|
|
<Badge data-testid="ssh-connection-badge" data-status="unverified" variant="secondary">
|
|
Unverified
|
|
</Badge>
|
|
);
|
|
}
|
|
return (
|
|
<Badge
|
|
data-testid="ssh-connection-badge"
|
|
data-status="trusted"
|
|
variant="default"
|
|
className="bg-green-600"
|
|
>
|
|
Trusted
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function TestResultDisplay({
|
|
result,
|
|
trust,
|
|
resultStale,
|
|
onTrustChange,
|
|
currentlyPinned,
|
|
}: {
|
|
result: SSHTestResult;
|
|
trust: boolean;
|
|
resultStale: boolean;
|
|
onTrustChange: (v: boolean) => void;
|
|
currentlyPinned?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
data-testid="ssh-test-result"
|
|
data-success={result.success ? "true" : "false"}
|
|
className="rounded-md border p-3 space-y-2"
|
|
>
|
|
<TestResultHeader success={result.success} totalMs={result.total_duration_ms} />
|
|
{result.steps.map((step: SSHTestStep) => (
|
|
<StepRow key={step.name} step={step} />
|
|
))}
|
|
{result.error && !result.steps.some((s) => s.error) && (
|
|
<p data-testid="ssh-test-result-error" className="text-sm text-red-600">
|
|
{result.error}
|
|
</p>
|
|
)}
|
|
{result.success && result.fingerprint && (
|
|
<FingerprintTrustBlock
|
|
fingerprint={result.fingerprint}
|
|
currentlyPinned={currentlyPinned}
|
|
trust={trust}
|
|
resultStale={resultStale}
|
|
onTrustChange={onTrustChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: number }) {
|
|
return (
|
|
<div
|
|
data-testid={success ? "ssh-test-result-success" : "ssh-test-result-failure"}
|
|
className="flex items-center gap-2 text-sm font-medium"
|
|
>
|
|
{success ? (
|
|
<IconCheck className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<IconX className="h-4 w-4 text-red-600" />
|
|
)}
|
|
{success ? "Connection test passed" : "Connection test failed"}
|
|
<span className="text-muted-foreground font-normal">({totalMs}ms)</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StepRow({ step }: { step: SSHTestStep }) {
|
|
const slug = step.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
return (
|
|
<div
|
|
data-testid={`ssh-test-step-${slug}`}
|
|
data-success={step.success ? "true" : "false"}
|
|
className="flex items-start gap-2 text-sm pl-2"
|
|
>
|
|
{step.success ? (
|
|
<IconCheck className="h-3 w-3 text-green-600 shrink-0 mt-1" />
|
|
) : (
|
|
<IconX className="h-3 w-3 text-red-600 shrink-0 mt-1" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span>{step.name}</span>
|
|
<span className="text-muted-foreground text-xs">({step.duration_ms}ms)</span>
|
|
</div>
|
|
{step.output && (
|
|
<p
|
|
data-testid={`ssh-test-step-${slug}-output`}
|
|
className="text-xs text-muted-foreground truncate font-mono"
|
|
>
|
|
{step.output}
|
|
</p>
|
|
)}
|
|
{step.error && (
|
|
<p data-testid={`ssh-test-step-${slug}-error`} className="text-xs text-red-600 truncate">
|
|
{step.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|