feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

@@ -3,12 +3,13 @@
*
* Core's classifier (ctx.ssh.classifyKeyboardInteractive) decides what a
* round is; this keeps the terminal's message protocol (totp_required,
* totp_retry, password_required, warpgate_auth_required) and its timeouts
* exactly as the client expects.
* totp_retry, password_required, "<handler>_auth_required" for a browser
* sign-in round) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import type {
PluginBrowserSignInRound,
PluginKeyboardInteractivePrompt,
PluginSsh,
PluginSshHost,
@@ -40,7 +41,6 @@ interface ResolvedCredentials {
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
@@ -54,15 +54,16 @@ interface AuthContext {
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
/** The handler whose browser round is waiting for "<id>_auth_continue". */
browserSignInId: string | null;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
browserSignInTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
const BROWSER_SIGN_IN_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
@@ -101,8 +102,8 @@ export class SSHAuthManager {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
case "browser":
this.handleBrowserSignIn(decision, finish);
return;
case "totp":
this.handleTotp(
@@ -124,40 +125,48 @@ export class SSHAuthManager {
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
/**
* A round finished in a browser. The message is named after the handler
* that claimed it (Termix-Mobile listens for these names), and the client
* answers with "<id>_auth_continue".
*/
private handleBrowserSignIn(
round: PluginBrowserSignInRound,
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.browserSignInId = round.id;
this.sendLog("auth", "info", `${round.label} sign-in required`);
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
type: `${round.id}_auth_required`,
kind: "browser",
label: round.label,
url: round.url,
securityKey: round.code,
instructions: round.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
this.context.browserSignInTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
this.context.log.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
this.context.browserSignInId = null;
this.context.log.warn("Browser sign-in timeout", {
operation: "browser_sign_in_timeout",
handler: round.id,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
message: `${round.label} sign-in timed out. Please reconnect.`,
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}, BROWSER_SIGN_IN_TIMEOUT_MS);
}
private handleTotp(
@@ -289,13 +298,13 @@ export class SSHAuthManager {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
if (this.context.browserSignInTimeout) {
clearTimeout(this.context.browserSignInTimeout);
this.context.browserSignInTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.browserSignInId = null;
this.context.keyboardInteractiveResponded = false;
}
}
@@ -64,7 +64,6 @@ interface ConnectToHostData {
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
useWarpgate?: boolean;
jumpHosts?: Array<{ hostId: number }>;
useSocks5?: boolean;
socks5Host?: string;
@@ -341,8 +340,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
let isCleaningUp = false;
let isShellInitializing = false;
let isDuplicateConnDiscarded = false;
let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
// The keyboard-interactive handler whose browser sign-in is pending.
let browserSignInId: string | null = null;
let browserSignInTimeout: NodeJS.Timeout | null = null;
let isAwaitingAuthCredentials = false;
let wsAlive = true;
@@ -418,7 +418,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
totpPromptSent = false;
warpgateAuthPromptSent = false;
browserSignInId = null;
}
ws.on(
@@ -907,19 +907,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "warpgate_auth_continue": {
if (keyboardInteractiveFinish) {
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
warpgateAuthPromptSent = false;
}
break;
}
case "reconnect_with_credentials": {
const credentialsData = data as {
cols: number;
@@ -1004,178 +991,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
break;
}
case "opkssh_start_auth": {
const opksshData = data as { hostId: number };
try {
await startAuthInteraction("opkssh", opksshData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start OPKSSH auth", error, {
operation: "opkssh_start_auth_error",
userId,
hostId: opksshData.hostId,
});
ws.send(
JSON.stringify({
type: "opkssh_error",
requestId: "",
error:
error instanceof PluginSshInteractionError
? error.message
: "Failed to start OPKSSH authentication",
}),
);
}
break;
}
case "opkssh_cancel": {
const cancelData = data as { requestId: string };
try {
await ctx.ssh.cancelInteraction("opkssh", {
requestId: String(cancelData?.requestId ?? ""),
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel OPKSSH auth", error, {
operation: "opkssh_cancel_error",
userId,
});
}
break;
}
case "opkssh_browser_opened": {
break;
}
case "opkssh_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error(
"Failed to reconnect after OPKSSH auth",
error,
{
operation: "opkssh_reconnect_error",
userId,
hostId: completedData.hostId,
},
);
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "vault_start_auth": {
const vaultData = data as { hostId: number };
try {
await startAuthInteraction("vault", vaultData.hostId, data);
} catch (error) {
sshLogger.error("Failed to start Vault auth", error, {
operation: "vault_start_auth_error",
userId,
hostId: vaultData.hostId,
});
ws.send(
JSON.stringify({
type: "vault_error",
hostId: vaultData.hostId,
error: getErrorMessage(
error,
"Failed to start Vault authentication",
),
}),
);
}
break;
}
case "vault_cancel": {
const cancelData = data as { hostId: number };
try {
await ctx.ssh.cancelInteraction("vault", {
hostId: cancelData.hostId,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel Vault auth", error, {
operation: "vault_cancel_error",
userId,
});
}
break;
}
case "vault_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after Vault auth", error, {
operation: "vault_reconnect_error",
userId,
hostId: completedData.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
break;
}
case "collab_subscribe": {
const { roomId } = (data ?? {}) as { roomId?: string };
if (typeof roomId !== "string" || !roomId) break;
@@ -1296,6 +1111,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
}
default:
if (await handleSignInMessage(type, data)) break;
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
userId,
@@ -1340,6 +1156,122 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
});
}
/**
* Messages a sign-in's UI sends: "<interaction>_start_auth", "_cancel"
* and "_auth_completed" for a provider's browser step, and
* "<handler>_auth_continue" for a pending keyboard-interactive browser
* round. False for anything else.
*/
async function handleSignInMessage(
type: string,
data: unknown,
): Promise<boolean> {
const match =
/^([a-z0-9-]+)_(start_auth|cancel|auth_completed|auth_continue)$/.exec(
type,
);
if (!match) return false;
const [, name, action] = match;
const payload = (data ?? {}) as Record<string, unknown>;
const hostId = Number(payload.hostId);
if (action === "start_auth") {
try {
await startAuthInteraction(name, hostId, payload);
} catch (error) {
sshLogger.error("Failed to start sign-in", error, {
operation: "sign_in_start_error",
interaction: name,
userId,
hostId,
});
ws.send(
JSON.stringify({
type: `${name}_error`,
requestId: "",
hostId,
error:
error instanceof PluginSshInteractionError
? error.message
: getErrorMessage(error, "Failed to start authentication"),
}),
);
}
return true;
}
if (action === "cancel") {
try {
await ctx.ssh.cancelInteraction(name, {
requestId:
typeof payload.requestId === "string"
? payload.requestId
: undefined,
hostId: Number.isInteger(hostId) ? hostId : undefined,
});
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel sign-in", error, {
operation: "sign_in_cancel_error",
interaction: name,
userId,
});
}
return true;
}
if (action === "auth_continue") {
if (name === browserSignInId && keyboardInteractiveFinish) {
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
keyboardInteractiveFinish([""]);
keyboardInteractiveFinish = null;
browserSignInId = null;
}
return true;
}
const completed = payload as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completed.cols || 80,
rows: completed.rows || 24,
hostConfig:
completed.hostConfig ||
({
id: completed.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after sign-in", error, {
operation: "sign_in_reconnect_error",
interaction: name,
userId,
hostId: completed.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
getErrorMessage(error),
}),
);
});
return true;
}
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand, tmuxAttachSession } =
data;
@@ -2360,7 +2292,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
port,
username,
authType: resolvedCredentials.authType,
warpgateAuthPromptSent,
browserSignInId,
isKeyboardInteractive,
hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish,
keyboardInteractiveResponded,
@@ -2680,9 +2612,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveResponded,
keyboardInteractiveFinish,
totpPromptSent,
warpgateAuthPromptSent,
browserSignInId,
totpTimeout,
warpgateAuthTimeout,
browserSignInTimeout,
totpAttempts: 0,
});
@@ -2709,7 +2641,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
resolvedCredentials as unknown as Parameters<
typeof sshAuthManager.handleKeyboardInteractive
>[5],
hostConfig,
// The resolved host carries the plugin host settings that
// keyboard-interactive handlers read.
connectTarget ?? hostConfig,
);
isKeyboardInteractive =
@@ -2719,10 +2653,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyboardInteractiveFinish =
sshAuthManager.context.keyboardInteractiveFinish;
totpPromptSent = sshAuthManager.context.totpPromptSent;
warpgateAuthPromptSent =
sshAuthManager.context.warpgateAuthPromptSent;
browserSignInId = sshAuthManager.context.browserSignInId;
totpTimeout = sshAuthManager.context.totpTimeout;
warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout;
browserSignInTimeout = sshAuthManager.context.browserSignInTimeout;
},
),
);
@@ -2759,7 +2692,6 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
keyType: resolvedCredentials.keyType,
certPublicKey: resolvedCredentials.certPublicKey,
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
useWarpgate: hostConfig.useWarpgate,
terminalConfig: hostConfig.terminalConfig,
jumpHosts: hostConfig.jumpHosts,
useSocks5: hostConfig.useSocks5,
@@ -2946,9 +2878,9 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
totpTimeout = null;
}
if (warpgateAuthTimeout) {
clearTimeout(warpgateAuthTimeout);
warpgateAuthTimeout = null;
if (browserSignInTimeout) {
clearTimeout(browserSignInTimeout);
browserSignInTimeout = null;
}
sshStream = null;
@@ -88,8 +88,7 @@ import {
TOTPDialog,
SSHAuthDialog,
PassphraseDialog,
WarpgateDialog,
OPKSSHDialog,
BrowserSignInDialog,
HostKeyVerificationDialog,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
@@ -314,22 +313,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
>("no_keyboard");
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
const [, setKeyboardInteractiveDetected] = useState(false);
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
const warpgateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [opksshDialog, setOpksshDialog] = useState<{
isOpen: boolean;
authUrl: string;
requestId: string;
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
/** Which issuer is asking (OPKSSH by default, "Step CA", ...). */
label?: string;
// A keyboard-interactive round finished in a browser, named after the
// handler that claimed it (see "<id>_auth_required").
const [browserSignIn, setBrowserSignIn] = useState<{
id: string;
label: string;
url: string;
code: string;
} | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const browserSignInTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Overlays contributed by plugins (a Tailscale check, say) hear the
// session's messages and may hold the connect timeout while they wait.
@@ -347,7 +339,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
};
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<TerminalHostConfig | null>(null);
@@ -839,8 +830,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current?.close();
},
disconnect: () => webSocketRef.current?.close(),
send: (type, data) => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type, data }));
}
},
connectPayload: () => ({
hostId: currentHostIdRef.current,
cols: terminal?.cols || 80,
rows: terminal?.rows || 24,
hostConfig: currentHostConfigRef.current,
}),
}),
[host, backgroundColor, updateConnectionError],
[host, backgroundColor, updateConnectionError, terminal],
);
const activityLoggingRef = useRef(false);
@@ -1087,38 +1089,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (onClose) onClose();
}
function handleWarpgateContinue() {
if (webSocketRef.current) {
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
}
webSocketRef.current.send(
JSON.stringify({
type: "warpgate_auth_continue",
data: {},
}),
);
setWarpgateAuthRequired(false);
setWarpgateAuthUrl("");
setWarpgateSecurityKey("");
function clearBrowserSignIn() {
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
browserSignInTimeoutRef.current = null;
}
setBrowserSignIn(null);
}
function handleWarpgateCancel() {
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
}
setWarpgateAuthRequired(false);
setWarpgateAuthUrl("");
setWarpgateSecurityKey("");
function handleBrowserSignInContinue() {
if (!browserSignIn || !webSocketRef.current) return;
webSocketRef.current.send(
JSON.stringify({
type: `${browserSignIn.id}_auth_continue`,
data: {},
}),
);
clearBrowserSignIn();
}
function handleBrowserSignInCancel() {
clearBrowserSignIn();
if (onClose) onClose();
}
function handleWarpgateOpenUrl() {
if (warpgateAuthUrl) {
window.open(warpgateAuthUrl, "_blank", "noopener,noreferrer");
function handleBrowserSignInOpenUrl() {
if (browserSignIn?.url) {
window.open(browserSignIn.url, "_blank", "noopener,noreferrer");
}
}
@@ -1356,9 +1353,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
warpgateTimeoutRef.current = null;
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
browserSignInTimeoutRef.current = null;
}
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({ type: "disconnect" }));
@@ -1912,7 +1909,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
updateConnectionError(errorMessage);
setIsConnecting(false);
} else if (msg.type === "connected") {
opksshFailedRef.current = false;
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
@@ -2084,51 +2080,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
},
isPush ? 300000 : 180000,
);
} else if (msg.type === "warpgate_auth_required") {
setWarpgateAuthRequired(true);
setWarpgateAuthUrl(msg.url || "");
setWarpgateSecurityKey(msg.securityKey || "N/A");
} else if (
msg.kind === "browser" &&
typeof msg.type === "string" &&
msg.type.endsWith("_auth_required")
) {
setBrowserSignIn({
id: msg.type.slice(0, -"_auth_required".length),
label: typeof msg.label === "string" ? msg.label : "",
url: msg.url || "",
code: msg.securityKey || "",
});
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (warpgateTimeoutRef.current) {
clearTimeout(warpgateTimeoutRef.current);
if (browserSignInTimeoutRef.current) {
clearTimeout(browserSignInTimeoutRef.current);
}
warpgateTimeoutRef.current = setTimeout(() => {
setWarpgateAuthRequired(false);
browserSignInTimeoutRef.current = setTimeout(() => {
setBrowserSignIn(null);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 300000);
} else if (msg.type === "opkssh_auth_required") {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (opksshFailedRef.current) {
setOpksshDialog(null);
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
updateConnectionError(t("terminal.opksshAuthFailed"));
addLog({
type: "error",
stage: "auth",
message: t("terminal.opksshAuthFailed"),
});
} else {
opksshFailedRef.current = true;
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_start_auth",
data: { hostId: msg.hostId },
}),
);
}
}
} else if (msg.type === "vault_auth_required") {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
@@ -2209,98 +2184,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
setVaultDialog({ stage: "error", error: msg.error });
setIsConnecting(false);
} else if (msg.type === "opkssh_status") {
if (connectionErrorRef.current) return;
if (msg.stage === "chooser") {
setOpksshDialog({
isOpen: true,
authUrl: msg.url || "",
requestId: msg.requestId || "",
stage: "chooser",
providers: msg.providers,
label: typeof msg.label === "string" ? msg.label : undefined,
});
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
}
opksshTimeoutRef.current = setTimeout(() => {
setOpksshDialog(null);
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 300000);
} else {
setOpksshDialog((prev) =>
prev ? { ...prev, stage: msg.stage } : null,
);
}
} else if (msg.type === "opkssh_completed") {
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
setOpksshDialog(null);
if (webSocketRef.current && terminal) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_auth_completed",
data: {
hostId: currentHostIdRef.current,
cols: terminal.cols || 80,
rows: terminal.rows || 24,
hostConfig: currentHostConfigRef.current,
},
}),
);
}
} else if (msg.type === "opkssh_error") {
if (connectionErrorRef.current) return;
opksshFailedRef.current = true;
if (opksshDialog) {
setOpksshDialog((prev) =>
prev ? { ...prev, stage: "error", error: msg.error } : null,
);
} else {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: msg.error,
});
}
setIsConnecting(false);
} else if (msg.type === "opkssh_timeout") {
if (connectionErrorRef.current) return;
opksshFailedRef.current = true;
if (opksshDialog) {
setOpksshDialog((prev) =>
prev
? {
...prev,
stage: "error",
error: t("terminal.opksshTimeout"),
}
: null,
);
} else {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: t("terminal.opksshTimeout"),
});
}
setIsConnecting(false);
} else if (msg.type === "opkssh_config_error") {
setOpksshDialog({
isOpen: true,
authUrl: "",
requestId: msg.requestId || "",
stage: "error",
error: msg.instructions || msg.error,
});
} else if (msg.type === "keyboard_interactive_available") {
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
@@ -2357,7 +2240,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
} else if (msg.type === "sessionAttached") {
isAttachingSessionRef.current = false;
opksshFailedRef.current = false;
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
@@ -3985,71 +3867,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<WarpgateDialog
isOpen={warpgateAuthRequired}
url={warpgateAuthUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
<BrowserSignInDialog
isOpen={browserSignIn !== null}
label={browserSignIn?.label ?? ""}
url={browserSignIn?.url ?? ""}
code={browserSignIn?.code ?? ""}
onContinue={handleBrowserSignInContinue}
onCancel={handleBrowserSignInCancel}
onOpenUrl={handleBrowserSignInOpenUrl}
backgroundColor={backgroundColor}
/>
{opksshDialog?.isOpen && (
<OPKSSHDialog
isOpen={opksshDialog.isOpen}
authUrl={opksshDialog.authUrl}
requestId={opksshDialog.requestId}
stage={opksshDialog.stage}
error={opksshDialog.error}
providers={opksshDialog.providers}
label={opksshDialog.label}
onCancel={() => {
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_cancel",
data: { requestId: opksshDialog.requestId },
}),
);
}
setOpksshDialog(null);
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
opksshTimeoutRef.current = null;
}
}}
onOpenUrl={() => {
window.open(opksshDialog.authUrl, "_blank");
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_browser_opened",
data: { requestId: opksshDialog.requestId },
}),
);
}
}}
onSelectProvider={(alias) => {
if (!opksshDialog.authUrl) return;
const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`;
window.open(selectUrl, "_blank");
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_browser_opened",
data: { requestId: opksshDialog.requestId },
}),
);
}
setOpksshDialog((prev) =>
prev ? { ...prev, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
)}
<ComponentSlot
slotId={TERMINAL_OVERLAY_SLOT}
when={{ host }}
@@ -10,7 +10,7 @@ import type { Host } from "../types";
* `openSidePanel(id)`, rendered with TerminalSidePanelProps.
* - "terminal.overlay" (components): always mounted over the terminal and
* given the session's server messages, for connection flows a plugin owns
* (a Tailscale check, say).
* (a Tailscale check, an OPKSSH sign-in). It can answer on the socket.
*
* Every contribution's `when` sees `{ host }`.
*/
@@ -79,4 +79,11 @@ export interface TerminalOverlayProps {
fail: (message: string) => void;
/** Closes the session's connection. */
disconnect: () => void;
/** Sends a message on the session's socket, as { type, data }. */
send: (type: string, data?: unknown) => void;
/**
* What the server needs to connect again after a sign-in finished: send it
* as the data of "<interaction>_auth_completed".
*/
connectPayload: () => Record<string, unknown>;
}