Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { AboutDialogProvider } from "./contexts/AboutDialogContext";
import { SettingsModal } from "./components/Settings/SettingsModal";
import { AboutDialog } from "./components/About/AboutDialog";
import { MuxGatewaySessionExpiredDialog } from "./components/MuxGatewaySessionExpiredDialog";
import { HostKeyVerificationDialog } from "./components/HostKeyVerificationDialog";
import { SplashScreenProvider } from "./components/splashScreens/SplashScreenProvider";
import { TutorialProvider } from "./contexts/TutorialContext";
import { PowerModeProvider } from "./contexts/PowerModeContext";
Expand Down Expand Up @@ -1071,6 +1072,7 @@ function AppInner() {
<SettingsModal />
<AboutDialog />
<MuxGatewaySessionExpiredDialog />
<HostKeyVerificationDialog />
</div>
</>
);
Expand Down
137 changes: 137 additions & 0 deletions src/browser/components/HostKeyVerificationDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { useAPI } from "@/browser/contexts/API";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
WarningBox,
WarningTitle,
WarningText,
} from "@/browser/components/ui/dialog";
import { Button } from "@/browser/components/ui/button";
import type { HostKeyVerificationRequest } from "@/common/orpc/schemas/ssh";

export function HostKeyVerificationDialog() {
const { api } = useAPI();
const [pendingQueue, setPendingQueue] = useState<HostKeyVerificationRequest[]>([]);
const pending = pendingQueue[0] ?? null;
const [responding, setResponding] = useState(false);

useEffect(() => {
if (!api) {
return;
}

const controller = new AbortController();
const { signal } = controller;

// Track the async iterator so we can explicitly close it on cleanup.
// Some oRPC iterators don't reliably terminate on abort alone;
// calling return() ensures the backend subscription finally block runs,
// which releases the responder lease and listener state.
let iteratorRef: AsyncIterator<HostKeyVerificationRequest> | undefined;

// Global subscription: backend can request host-key verification at any time.
// Queue pending requests so concurrent prompts are handled FIFO without drops.
(async () => {
try {
const iterable = await api.ssh.hostKeyVerification.subscribe(undefined, { signal });
iteratorRef = iterable[Symbol.asyncIterator]();

for await (const request of iterable) {
if (signal.aborted) {
break;
}

setPendingQueue((prev) =>
prev.some((item) => item.requestId === request.requestId) ? prev : [...prev, request]
);
}
} catch {
// Subscription closed (cleanup/reconnect): no-op
}
})();

return () => {
controller.abort();
void iteratorRef?.return?.(undefined);
};
}, [api]);

const respond = async (accept: boolean) => {
if (!api || !pending || responding) {
return;
}

const requestId = pending.requestId;
setResponding(true);

try {
await api.ssh.hostKeyVerification.respond({ requestId, accept });
} finally {
setResponding(false);
setPendingQueue((prev) => prev.filter((item) => item.requestId !== requestId));
Comment on lines +74 to +76

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending prompt when respond RPC fails

The dialog always removes the current request in finally, even if api.ssh.hostKeyVerification.respond() throws (for example during renderer/backend reconnects). That drops the UI prompt while the backend request can still be pending until timeout, so the user cannot retry and the SSH handshake fails despite having clicked accept/reject.

Useful? React with πŸ‘Β / πŸ‘Ž.

}
};

return (
<Dialog
open={pending !== null}
onOpenChange={(open) => {
// Treat dismiss/escape as explicit rejection so backend unblocks promptly.
if (!open && !responding) {
void respond(false);
}
}}
>
<DialogContent maxWidth="500px" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Unknown SSH Host</DialogTitle>
<DialogDescription>
{pending?.prompt ?? (
<>
The authenticity of host{" "}
<code className="text-foreground font-semibold">{pending?.host}</code> cannot be
established.
</>
)}
</DialogDescription>
</DialogHeader>

<div className="bg-background-secondary border-border rounded p-3 font-mono text-sm">
<div className="text-muted">{pending?.keyType} key fingerprint:</div>
<div className="text-foreground mt-1 break-all select-all">{pending?.fingerprint}</div>
</div>

<WarningBox>
<WarningTitle>Host Key Verification</WarningTitle>
<WarningText>Accepting will add the host to your known_hosts file.</WarningText>
</WarningBox>

<DialogFooter className="justify-center">
<Button
variant="secondary"
disabled={responding}
onClick={() => {
void respond(false);
}}
>
Reject
</Button>
<Button
variant="default"
disabled={responding}
onClick={() => {
void respond(true);
}}
>
{responding ? "Connecting..." : "Accept & Connect"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
1 change: 1 addition & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
sessionUsageService: services.sessionUsageService,
signingService: services.signingService,
coderService: services.coderService,
hostKeyVerificationService: services.hostKeyVerificationService,
};

// Use the actual createOrpcServer function
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ async function createTestServer(): Promise<TestServerHandle> {
sessionUsageService: services.sessionUsageService,
signingService: services.signingService,
coderService: services.coderService,
hostKeyVerificationService: services.hostKeyVerificationService,
};

// Use the actual createOrpcServer function
Expand Down
8 changes: 8 additions & 0 deletions src/common/constants/ssh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Maximum time (ms) to wait for the user to accept/reject a host-key
* verification prompt in the UI dialog. Shared across:
* - HostKeyVerificationService (auto-reject timeout)
* - OpenSSH connection pool (probe deadline extension)
* - SSH2 connection pool (readyTimeout extension)
*/
export const HOST_KEY_APPROVAL_TIMEOUT_MS = 60_000;
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export {
signing,
type SigningCapabilities,
type SignatureEnvelope,
ssh,
terminal,
tokenizer,
update,
Expand Down
19 changes: 19 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SendMessageErrorSchema } from "./errors";
import { BranchListResultSchema, FilePartSchema, MuxMessageSchema } from "./message";
import { ProjectConfigSchema, SectionConfigSchema } from "./project";
import { ResultSchema } from "./result";
import { HostKeyVerificationRequestSchema } from "./ssh";
import { RuntimeConfigSchema, RuntimeAvailabilitySchema } from "./runtime";
import { SecretSchema } from "./secrets";
import {
Expand Down Expand Up @@ -1695,3 +1696,21 @@ export const debug = {
output: z.boolean(), // true if error was triggered on an active stream
},
};

export const ssh = {
hostKeyVerification: {
subscribe: {
input: z.void(),
output: eventIterator(HostKeyVerificationRequestSchema),
},
respond: {
input: z
.object({
requestId: z.string(),
accept: z.boolean(),
})
.strict(),
output: ResultSchema(z.void(), z.string()),
},
},
};
11 changes: 11 additions & 0 deletions src/common/orpc/schemas/ssh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from "zod";

export const HostKeyVerificationRequestSchema = z.object({
requestId: z.string(),
host: z.string(),
keyType: z.string(),
fingerprint: z.string(),
prompt: z.string(),
});

export type HostKeyVerificationRequest = z.infer<typeof HostKeyVerificationRequestSchema>;
2 changes: 2 additions & 0 deletions src/node/orpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { SessionUsageService } from "@/node/services/sessionUsageService";
import type { TaskService } from "@/node/services/taskService";
import type { PolicyService } from "@/node/services/policyService";
import type { CoderService } from "@/node/services/coderService";
import type { HostKeyVerificationService } from "@/node/services/hostKeyVerificationService";

export interface ORPCContext {
config: Config;
Expand Down Expand Up @@ -61,5 +62,6 @@ export interface ORPCContext {
policyService: PolicyService;
signingService: SigningService;
coderService: CoderService;
hostKeyVerificationService: HostKeyVerificationService;
headers?: IncomingHttpHeaders;
}
37 changes: 37 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
FrontendWorkspaceMetadataSchemaType,
} from "@/common/orpc/types";
import type { WorkspaceMetadata } from "@/common/types/workspace";
import type { HostKeyVerificationRequest } from "@/common/orpc/schemas/ssh";
import { createAuthMiddleware } from "./authMiddleware";
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
import { clearLogFiles, getLogFilePath } from "@/node/services/log";
Expand Down Expand Up @@ -3839,6 +3840,42 @@ export const router = (authToken?: string) => {
return { success: true };
}),
},
ssh: {
hostKeyVerification: {
subscribe: t
.input(schemas.ssh.hostKeyVerification.subscribe.input)
.output(schemas.ssh.hostKeyVerification.subscribe.output)
.handler(async function* ({ context, signal }) {
if (signal?.aborted) return;

const service = context.hostKeyVerificationService;
const releaseResponder = service.registerInteractiveResponder();
const queue = createAsyncEventQueue<HostKeyVerificationRequest>();

const onRequest = (req: HostKeyVerificationRequest) => queue.push(req);
service.on("request", onRequest);

const onAbort = () => queue.end();
signal?.addEventListener("abort", onAbort, { once: true });

try {
yield* queue.iterate();
} finally {
signal?.removeEventListener("abort", onAbort);
releaseResponder();
queue.end();
service.off("request", onRequest);
}
}),
respond: t
.input(schemas.ssh.hostKeyVerification.respond.input)
.output(schemas.ssh.hostKeyVerification.respond.output)
.handler(({ context, input }) => {
context.hostKeyVerificationService.respond(input.requestId, input.accept);
return Ok(undefined);
}),
},
},
});
};

Expand Down
38 changes: 37 additions & 1 deletion src/node/runtime/SSH2ConnectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,19 @@ import * as path from "path";
import { spawn, type ChildProcess } from "child_process";
import { Duplex } from "stream";
import type { Client } from "ssh2";
import { HOST_KEY_APPROVAL_TIMEOUT_MS } from "@/common/constants/ssh";
import { getErrorMessage } from "@/common/utils/errors";
import { log } from "@/node/services/log";
import { attachStreamErrorHandler } from "@/node/utils/streamErrors";
import type { SSHConnectionConfig } from "./sshConnectionPool";
import { resolveSSHConfig, type ResolvedSSHConfig } from "./sshConfigParser";
import type { HostKeyVerificationService } from "@/node/services/hostKeyVerificationService";

let hostKeyService: HostKeyVerificationService | undefined;

export function setHostKeyVerificationService(svc: HostKeyVerificationService): void {
hostKeyService = svc;
}

/**
* Connection health status
Expand Down Expand Up @@ -494,6 +502,8 @@ export class SSH2ConnectionPool {
const readableKeys = await resolvePrivateKeys(resolvedConfigWithIdentities.identityFiles);
const keysToTry: Array<Buffer | undefined> =
readableKeys.length > 0 ? readableKeys : [undefined];
const verificationService = hostKeyService;
const canPromptInteractively = verificationService?.hasInteractiveResponder() === true;

const connectWithKey = async (
privateKey: Buffer | undefined,
Expand Down Expand Up @@ -607,10 +617,36 @@ export class SSH2ConnectionPool {
username,
agent: agentOverride,
sock: proxy?.sock,
readyTimeout: timeoutMs,
// hostVerifier can wait for user approval in the UI dialog,
// so keep the handshake alive long enough for that interaction.
readyTimeout: canPromptInteractively
? Math.max(timeoutMs, HOST_KEY_APPROVAL_TIMEOUT_MS)
: timeoutMs,
keepaliveInterval: 5000,
keepaliveCountMax: 2,
...(privateKey ? { privateKey } : {}),
// Host key verification
...(canPromptInteractively && verificationService
? {
hostHash: "sha256" as const,
hostVerifier: (
fingerprint: string,
verify: (accept: boolean) => void
): boolean => {
void verificationService
.requestVerification({
host: resolvedConfig.hostName,
keyType: "unknown", // ssh2 doesn't expose key type in this callback
fingerprint: `SHA256:${fingerprint}`,
prompt: `The authenticity of host '${resolvedConfig.hostName}' can't be established.\nFingerprint: SHA256:${fingerprint}`,
})
.then(verify);
return true;
},
}
: {
hostVerifier: () => true,
}),
};

client.connect(connectOptions);
Expand Down
5 changes: 5 additions & 0 deletions src/node/runtime/SSHRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ export class SSHRuntime extends RemoteRuntime {

const command = `bash -lc ${shescape.quote(script)}`;

// Wait for connection establishment (including host-key confirmation) before
// starting the 10s command timeout. Otherwise users who take >10s to accept
// the host key prompt will hit a false timeout immediately after acceptance.
await this.transport.acquireConnection();

const abortController = createAbortController(10_000);
try {
const result = await execBuffered(this, command, {
Expand Down
Loading
Loading