1997 lines
83 KiB
TypeScript
1997 lines
83 KiB
TypeScript
|
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
||
|
|
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
|
||
|
|
import { logger } from "../../../lib/logger";
|
||
|
|
import type { Host, Identity, SSHKey } from "../../../types";
|
||
|
|
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
|
||
|
|
import { setTerminalBootEpoch } from "../../../domain/terminalBootEpoch";
|
||
|
|
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
||
|
|
export type {
|
||
|
|
PendingAuth,
|
||
|
|
SessionLogConfig,
|
||
|
|
TerminalSessionDataMeta,
|
||
|
|
TerminalSessionStartersContext,
|
||
|
|
} from "./createTerminalSessionStarters.types";
|
||
|
|
export { normalizeStartupCommandDelay, splitStartupCommandLines } from "./terminalStartupCommands";
|
||
|
|
import {
|
||
|
|
acknowledgeDroppedTerminalDisplayBytes,
|
||
|
|
attachSessionToTerminal,
|
||
|
|
buildTermEnv,
|
||
|
|
closeOrphanBackendSession,
|
||
|
|
createBootAttemptGuard,
|
||
|
|
getFlowController,
|
||
|
|
notePendingOutputScrollIfEnabled,
|
||
|
|
resetTerminalLineTimestampState,
|
||
|
|
tryAttachSessionToTerminal,
|
||
|
|
writeSessionData,
|
||
|
|
writeTerminalLine,
|
||
|
|
} from "./terminalSessionAttachment";
|
||
|
|
import { teardownTerminalOutputPipeline } from "./terminalOutputPipeline";
|
||
|
|
import { resetTerminalSyncBlockFilter } from "./terminalSyncBlockFilter";
|
||
|
|
import { flushTerminalWriteCoalescer } from "./terminalWriteCoalescer";
|
||
|
|
import { isConnectionTokenCurrent, registerConnectionToken, runDistroDetection } from "./terminalDistroDetection";
|
||
|
|
import { resolveStartupCommand, scheduleStartupCommand } from "./terminalStartupCommands";
|
||
|
|
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
|
||
|
|
import {
|
||
|
|
isEncryptedCredentialPlaceholder,
|
||
|
|
needsVaultStoredKeyHydration,
|
||
|
|
sanitizeCredentialValue,
|
||
|
|
} from "../../../domain/credentials";
|
||
|
|
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||
|
|
import {
|
||
|
|
resolveHostKeepalive,
|
||
|
|
resolveTelnetPassword,
|
||
|
|
resolveTelnetPort,
|
||
|
|
resolveTelnetUsername,
|
||
|
|
} from "../../../domain/host";
|
||
|
|
import {
|
||
|
|
findIncompleteProxyIdentityId,
|
||
|
|
findMissingProxyIdentityId,
|
||
|
|
formatIncompleteProxyIdentityMessage,
|
||
|
|
formatMissingProxyIdentityMessage,
|
||
|
|
hasUnreadableProxyCredential,
|
||
|
|
hasUsableProxyConfig,
|
||
|
|
resolveProxyConfigAuth,
|
||
|
|
} from "../../../domain/proxyProfiles";
|
||
|
|
import {
|
||
|
|
advanceMonotonicConnectionProgress,
|
||
|
|
resolveHopConnectionProgress,
|
||
|
|
} from "../connectionProgress";
|
||
|
|
import { hasConnectionPassedTcpDial } from "../connectionTimeouts";
|
||
|
|
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||
|
|
import { isPluginHostProtocol, sanitizePluginConnection } from "../../../domain/pluginConnection";
|
||
|
|
import { hydrateVaultStoredKeys } from "../../../infrastructure/persistence/secureFieldAdapter";
|
||
|
|
|
||
|
|
const collectConnectKeyIds = (
|
||
|
|
host: Host,
|
||
|
|
jumpHosts: Host[],
|
||
|
|
identities: Identity[] | undefined,
|
||
|
|
pendingAuth: { authMethod?: string; keyId?: string } | null,
|
||
|
|
): Set<string> => {
|
||
|
|
const ids = new Set<string>();
|
||
|
|
const addHostKeyId = (
|
||
|
|
candidate: Host,
|
||
|
|
override?: { authMethod?: string; keyId?: string } | null,
|
||
|
|
) => {
|
||
|
|
const identity = candidate.identityId
|
||
|
|
? identities?.find((item) => item.id === candidate.identityId)
|
||
|
|
: undefined;
|
||
|
|
const selectedAuthMethod = override?.authMethod || identity?.authMethod || candidate.authMethod;
|
||
|
|
if (selectedAuthMethod === "password") return;
|
||
|
|
const keyId = override?.keyId || identity?.keyId || candidate.identityFileId;
|
||
|
|
if (keyId) ids.add(keyId);
|
||
|
|
};
|
||
|
|
addHostKeyId(host, pendingAuth);
|
||
|
|
for (const jumpHost of jumpHosts) addHostKeyId(jumpHost);
|
||
|
|
return ids;
|
||
|
|
};
|
||
|
|
|
||
|
|
const hydrateConnectKeysIfNeeded = (
|
||
|
|
sourceKeys: SSHKey[],
|
||
|
|
keyIds: Set<string>,
|
||
|
|
) => {
|
||
|
|
const candidates = sourceKeys.filter((key) => keyIds.has(key.id));
|
||
|
|
if (!candidates.some((key) => needsVaultStoredKeyHydration(key))) return null;
|
||
|
|
return hydrateVaultStoredKeys(candidates).then(({ keys: hydrated, unreadableKeyIds }) => {
|
||
|
|
const byId = new Map(hydrated.map((key) => [key.id, key] as const));
|
||
|
|
return {
|
||
|
|
keys: sourceKeys.map((key) => byId.get(key.id) ?? key),
|
||
|
|
unreadableKeyIds,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const TELNET_SESSION_REPLACED_ERROR = "Telnet session start was replaced";
|
||
|
|
const JUMP_HOST_AUTH_FAILED_PREFIX = "Jump host authentication failed";
|
||
|
|
|
||
|
|
const createPluginConnectionRequestId = (): string => {
|
||
|
|
const randomId = globalThis.crypto?.randomUUID?.()
|
||
|
|
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 18)}`;
|
||
|
|
return `plugin-connection-${randomId}`.slice(0, 128);
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatPluginDiagnosticLines = (
|
||
|
|
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
|
||
|
|
): string[] => (diagnostics ?? [])
|
||
|
|
.map((issue) => `[Plugin ${issue.severity}] ${issue.message}`);
|
||
|
|
|
||
|
|
const formatPluginConnectionExitMessage = (event: TerminalSessionExitEvent): string => {
|
||
|
|
const lines = [
|
||
|
|
event.error
|
||
|
|
? `[Plugin connection closed: ${event.error}]`
|
||
|
|
: "[Plugin connection closed]",
|
||
|
|
...formatPluginDiagnosticLines(event.diagnostics),
|
||
|
|
];
|
||
|
|
return `\r\n${lines.join("\r\n")}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isAuthFailureMessage = (message: string): boolean => {
|
||
|
|
const normalized = message.toLowerCase();
|
||
|
|
return normalized.includes("all configured authentication methods failed") ||
|
||
|
|
normalized.includes("authentication failed") ||
|
||
|
|
normalized.includes("too many authentication failures") ||
|
||
|
|
/permission denied\s*\(/.test(normalized) ||
|
||
|
|
normalized.includes("no authentication methods available");
|
||
|
|
};
|
||
|
|
|
||
|
|
const isJumpHostAuthError = (err: unknown, message: string): boolean =>
|
||
|
|
Boolean(
|
||
|
|
err instanceof Error &&
|
||
|
|
(err as Error & { isJumpHostAuthError?: boolean }).isJumpHostAuthError,
|
||
|
|
) || message.includes(JUMP_HOST_AUTH_FAILED_PREFIX);
|
||
|
|
|
||
|
|
export const getMissingChainHostIds = (
|
||
|
|
host: Host,
|
||
|
|
resolvedChainHosts: Host[],
|
||
|
|
): string[] => {
|
||
|
|
const requestedIds = host.hostChain?.hostIds ?? [];
|
||
|
|
if (requestedIds.length === 0) return [];
|
||
|
|
const resolvedIds = new Set(resolvedChainHosts.map((chainHost) => chainHost.id));
|
||
|
|
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cancellation handle for one boot attempt. The mount effect owns the
|
||
|
|
* controller and aborts it in cleanup, so a start that is still awaiting the
|
||
|
|
* bridge stops counting as the current attempt and closes any session it wins.
|
||
|
|
*/
|
||
|
|
export type TerminalSessionStartOptions = {
|
||
|
|
signal?: AbortSignal;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
|
||
|
|
const globalTerminalSettings = {
|
||
|
|
verifyHostKeys: true,
|
||
|
|
keepaliveInterval: 30,
|
||
|
|
keepaliveCountMax: 10,
|
||
|
|
...(ctx.terminalSettings ?? {}),
|
||
|
|
};
|
||
|
|
let fallbackDisposeTelnetEchoMode: (() => void) | null = null;
|
||
|
|
|
||
|
|
const tr = (key: string, fallback: string): string => {
|
||
|
|
const translated = ctx.t?.(key);
|
||
|
|
if (!translated || translated === key) return fallback;
|
||
|
|
return translated;
|
||
|
|
};
|
||
|
|
|
||
|
|
const abortSessionStartAfterUnmount = () => {
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
ctx.setProgressValue(0);
|
||
|
|
ctx.setChainProgress(null);
|
||
|
|
};
|
||
|
|
|
||
|
|
const createAttemptGuards = (options?: TerminalSessionStartOptions) => {
|
||
|
|
const bootEpoch = ctx.bootEpochRef?.current ?? 0;
|
||
|
|
const isBootEpochCurrent = createBootAttemptGuard(ctx);
|
||
|
|
// An aborted boot is never the current attempt, so every existing guard
|
||
|
|
// (orphan close, attach refusal, UI suppression) also covers cancellation.
|
||
|
|
const isCurrentAttempt = () => options?.signal?.aborted !== true && isBootEpochCurrent();
|
||
|
|
return {
|
||
|
|
bootEpoch,
|
||
|
|
isCurrentAttempt,
|
||
|
|
ignoreStaleAttemptUi: () => !isCurrentAttempt(),
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const consumeRestoreCwdIntent = (term: XTerm, id: string): void => {
|
||
|
|
const intent = ctx.restoreCwdIntentRef?.current;
|
||
|
|
if (!intent) return;
|
||
|
|
ctx.restoreCwdIntentRef.current = null;
|
||
|
|
ctx.setProgressLogs((prev) => [...prev, tr("terminal.restore.cwdLog", `Restoring working directory: ${intent.cwd}`)
|
||
|
|
.replace("{cwd}", intent.cwd)]);
|
||
|
|
ctx.terminalBackend.writeToSession(id, `${intent.command}\r`, { automated: true });
|
||
|
|
ctx.onRestoreCwdIntentConsumed?.(intent.cwd);
|
||
|
|
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, intent.command);
|
||
|
|
};
|
||
|
|
|
||
|
|
const resolveSavedSudoAutofillPassword = (): string | undefined => {
|
||
|
|
const pendingAuth = ctx.pendingAuthRef.current;
|
||
|
|
if (pendingAuth?.savedToHost && pendingAuth.password) {
|
||
|
|
return sanitizeCredentialValue(pendingAuth.password);
|
||
|
|
}
|
||
|
|
if (ctx.sudoAutofillPasswordRef) {
|
||
|
|
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
|
||
|
|
}
|
||
|
|
return sanitizeCredentialValue(ctx.sudoAutofillPassword);
|
||
|
|
};
|
||
|
|
|
||
|
|
const resolveSudoAutofillCandidates = () =>
|
||
|
|
ctx.sudoAutofillCandidatesRef?.current ?? ctx.sudoAutofillCandidates ?? [];
|
||
|
|
|
||
|
|
const clearTelnetEchoMode = ({ resetLocalEcho = true }: { resetLocalEcho?: boolean } = {}) => {
|
||
|
|
ctx.disposeTelnetEchoModeRef?.current?.();
|
||
|
|
if (ctx.disposeTelnetEchoModeRef) ctx.disposeTelnetEchoModeRef.current = null;
|
||
|
|
fallbackDisposeTelnetEchoMode?.();
|
||
|
|
fallbackDisposeTelnetEchoMode = null;
|
||
|
|
if (resetLocalEcho && ctx.telnetLocalEchoRef) ctx.telnetLocalEchoRef.current = false;
|
||
|
|
};
|
||
|
|
|
||
|
|
const attachTelnetEchoMode = (
|
||
|
|
backendSessionId: string,
|
||
|
|
{ resetLocalEcho = true }: { resetLocalEcho?: boolean } = {},
|
||
|
|
) => {
|
||
|
|
if (ctx.host.protocol !== "telnet") return;
|
||
|
|
if (!ctx.telnetLocalEchoRef || !ctx.terminalBackend.onTelnetEchoMode) return;
|
||
|
|
clearTelnetEchoMode({ resetLocalEcho });
|
||
|
|
if (resetLocalEcho) ctx.telnetLocalEchoRef.current = false;
|
||
|
|
let receivedLiveUpdate = false;
|
||
|
|
const dispose = ctx.terminalBackend.onTelnetEchoMode(
|
||
|
|
backendSessionId,
|
||
|
|
(evt) => {
|
||
|
|
receivedLiveUpdate = true;
|
||
|
|
ctx.telnetLocalEchoRef!.current = Boolean(evt.localEcho);
|
||
|
|
},
|
||
|
|
) ?? null;
|
||
|
|
void ctx.terminalBackend.getTelnetEchoMode?.(backendSessionId).then((mode) => {
|
||
|
|
if (!receivedLiveUpdate && mode?.success) {
|
||
|
|
ctx.telnetLocalEchoRef!.current = Boolean(mode.localEcho);
|
||
|
|
}
|
||
|
|
}).catch(() => {});
|
||
|
|
if (ctx.disposeTelnetEchoModeRef) {
|
||
|
|
ctx.disposeTelnetEchoModeRef.current = dispose;
|
||
|
|
} else {
|
||
|
|
fallbackDisposeTelnetEchoMode = dispose;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startSSH = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
// Correlate host-key prompts with this boot so a superseded start cannot
|
||
|
|
// reopen approval UI after disconnect → reconnect.
|
||
|
|
setTerminalBootEpoch(ctx.sessionId, bootEpoch);
|
||
|
|
if (!ctx.terminalBackend.backendAvailable()) {
|
||
|
|
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
|
||
|
|
writeTerminalLine(
|
||
|
|
ctx,
|
||
|
|
term,
|
||
|
|
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]",
|
||
|
|
);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
|
||
|
|
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
|
||
|
|
if (missingChainHostIds.length > 0) {
|
||
|
|
const base = tr(
|
||
|
|
"terminal.auth.jumpHostMissing",
|
||
|
|
"A configured jump host is missing. Open host settings and repair the jump host chain.",
|
||
|
|
);
|
||
|
|
const suffix = missingChainHostIds.length > 2
|
||
|
|
? ` +${missingChainHostIds.length - 2}`
|
||
|
|
: "";
|
||
|
|
const message = `${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`;
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const pendingAuth = ctx.pendingAuthRef.current;
|
||
|
|
const pendingKeyHydration = hydrateConnectKeysIfNeeded(
|
||
|
|
ctx.keys,
|
||
|
|
collectConnectKeyIds(ctx.host, ctx.resolvedChainHosts, ctx.identities, pendingAuth),
|
||
|
|
);
|
||
|
|
const { keys, unreadableKeyIds } = pendingKeyHydration
|
||
|
|
? await pendingKeyHydration
|
||
|
|
: { keys: ctx.keys, unreadableKeyIds: new Set<string>() };
|
||
|
|
if (pendingKeyHydration && !isCurrentAttempt()) return;
|
||
|
|
const resolvedAuth = resolveHostAuth({
|
||
|
|
host: ctx.host,
|
||
|
|
keys,
|
||
|
|
identities: ctx.identities,
|
||
|
|
override: pendingAuth
|
||
|
|
? {
|
||
|
|
authMethod: pendingAuth.authMethod,
|
||
|
|
username: pendingAuth.username,
|
||
|
|
password: pendingAuth.password,
|
||
|
|
keyId: pendingAuth.keyId,
|
||
|
|
passphrase: pendingAuth.passphrase,
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
const effectiveUsername = resolvedAuth.username || "root";
|
||
|
|
const key = resolvedAuth.key;
|
||
|
|
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||
|
|
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||
|
|
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||
|
|
const hasEncryptedPrimaryKey = Boolean(
|
||
|
|
key && (unreadableKeyIds.has(key.id) || isEncryptedCredentialPlaceholder(key.privateKey)),
|
||
|
|
);
|
||
|
|
|
||
|
|
const isAuthError = (err: unknown): boolean => {
|
||
|
|
if (!(err instanceof Error)) return false;
|
||
|
|
return isAuthFailureMessage(err.message);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||
|
|
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findMissingProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
const message = formatMissingProxyIdentityMessage(ctx.host.label || ctx.host.hostname);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findIncompleteProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
const message = formatIncompleteProxyIdentityMessage(ctx.host.label || ctx.host.hostname);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const proxyConfig = ctx.host.proxyConfig
|
||
|
|
? resolveProxyConfigAuth(ctx.host.proxyConfig, ctx.identities)
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||
|
|
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||
|
|
if (unresolvedJumpProxyHost) {
|
||
|
|
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const unresolvedJumpProxyIdentityHost = ctx.resolvedChainHosts.find((jumpHost) =>
|
||
|
|
findMissingProxyIdentityId(jumpHost.proxyConfig, ctx.identities),
|
||
|
|
);
|
||
|
|
if (unresolvedJumpProxyIdentityHost) {
|
||
|
|
const message = formatMissingProxyIdentityMessage(
|
||
|
|
unresolvedJumpProxyIdentityHost.label || unresolvedJumpProxyIdentityHost.hostname,
|
||
|
|
);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const incompleteJumpProxyIdentityHost = ctx.resolvedChainHosts.find((jumpHost) =>
|
||
|
|
findIncompleteProxyIdentityId(jumpHost.proxyConfig, ctx.identities),
|
||
|
|
);
|
||
|
|
if (incompleteJumpProxyIdentityHost) {
|
||
|
|
const message = formatIncompleteProxyIdentityMessage(
|
||
|
|
incompleteJumpProxyIdentityHost.label || incompleteJumpProxyIdentityHost.hostname,
|
||
|
|
);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
|
||
|
|
const jumpAuth = resolveHostAuth({
|
||
|
|
host: jumpHost,
|
||
|
|
keys,
|
||
|
|
identities: ctx.identities,
|
||
|
|
});
|
||
|
|
const jumpKey = jumpAuth.key;
|
||
|
|
const rawJumpPassword = jumpAuth.password;
|
||
|
|
const rawJumpPrivateKey = jumpKey?.privateKey;
|
||
|
|
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
|
||
|
|
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||
|
|
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||
|
|
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||
|
|
const jumpAllowsLocalIdentityFallback = !jumpAuth.keyId;
|
||
|
|
const jumpReferenceKeyPath = jumpAuth.authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: jumpKey?.source === 'reference' ? jumpKey.filePath : undefined;
|
||
|
|
const jumpIdentityFilePaths = jumpAuth.authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: jumpReferenceKeyPath
|
||
|
|
? [jumpReferenceKeyPath]
|
||
|
|
: jumpAllowsLocalIdentityFallback
|
||
|
|
? jumpHost.identityFilePaths
|
||
|
|
: undefined;
|
||
|
|
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey, jumpAuth.authMethod);
|
||
|
|
const hasJumpKeyMaterial = Boolean(
|
||
|
|
jumpAgentAuth.useSshAgent || jumpPrivateKey || jumpIdentityFilePaths?.length,
|
||
|
|
);
|
||
|
|
const hasConfiguredJumpProxyEndpoint =
|
||
|
|
index === 0 &&
|
||
|
|
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||
|
|
const hasEncryptedJumpProxyCredential =
|
||
|
|
hasConfiguredJumpProxyEndpoint &&
|
||
|
|
hasUnreadableProxyCredential(jumpHost.proxyConfig, ctx.identities);
|
||
|
|
|
||
|
|
const hasEncryptedJumpCredential =
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||
|
|
Boolean(jumpKey && unreadableKeyIds.has(jumpKey.id)) ||
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||
|
|
|
||
|
|
if (hasEncryptedJumpProxyCredential || (
|
||
|
|
jumpAuth.authMethod !== "auto"
|
||
|
|
&& hasEncryptedJumpCredential
|
||
|
|
&& !jumpPassword
|
||
|
|
&& !hasJumpKeyMaterial
|
||
|
|
)) {
|
||
|
|
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve keepalive for THIS hop. Each jump host carries its own
|
||
|
|
// override toggle, so a bastion that is a router (interval=0) can
|
||
|
|
// coexist with a cloud target host (interval=30) in the same chain.
|
||
|
|
const hopKeepalive = resolveHostKeepalive(jumpHost, globalTerminalSettings);
|
||
|
|
const hopConnectionTimeouts = resolveHostSshConnectionTimeouts(jumpHost);
|
||
|
|
|
||
|
|
return {
|
||
|
|
hostname: jumpHost.hostname,
|
||
|
|
hostId: jumpHost.id,
|
||
|
|
port: jumpHost.port || 22,
|
||
|
|
username: jumpAuth.username || "root",
|
||
|
|
authMethod: jumpAuth.authMethod,
|
||
|
|
requiresMfa: !!jumpHost.requiresMfa,
|
||
|
|
password: jumpPassword,
|
||
|
|
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||
|
|
certificate: jumpKey?.certificate,
|
||
|
|
passphrase: jumpPassphrase,
|
||
|
|
publicKey: jumpKey?.publicKey,
|
||
|
|
keyId: jumpAuth.keyId,
|
||
|
|
keySource: jumpKey?.source,
|
||
|
|
label: jumpHost.label,
|
||
|
|
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||
|
|
? resolveProxyConfigAuth(jumpHost.proxyConfig, ctx.identities)
|
||
|
|
: undefined,
|
||
|
|
identityFilePaths: jumpIdentityFilePaths,
|
||
|
|
...jumpAgentAuth,
|
||
|
|
keepaliveInterval: hopKeepalive.interval,
|
||
|
|
keepaliveCountMax: hopKeepalive.countMax,
|
||
|
|
sshTcpConnectTimeoutMs: hopConnectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||
|
|
sshAuthReadyTimeoutMs: hopConnectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||
|
|
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||
|
|
legacyAlgorithms: jumpHost.legacyAlgorithms,
|
||
|
|
skipEcdsaHostKey: jumpHost.skipEcdsaHostKey,
|
||
|
|
algorithmOverrides: jumpHost.algorithms,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
|
||
|
|
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
const message = tr(
|
||
|
|
"terminal.auth.proxyCredentialsUnavailable",
|
||
|
|
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||
|
|
);
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (jumpHostsWithUnavailableCredentials.length > 0) {
|
||
|
|
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
|
||
|
|
const suffix =
|
||
|
|
jumpHostsWithUnavailableCredentials.length > 2
|
||
|
|
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
|
||
|
|
: "";
|
||
|
|
const base = tr(
|
||
|
|
"terminal.auth.jumpCredentialsUnavailable",
|
||
|
|
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
|
||
|
|
);
|
||
|
|
const message = `${base} (${jumpList}${suffix})`;
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const totalHops = jumpHosts.length + 1;
|
||
|
|
let unsubscribeChainProgress: (() => void) | undefined;
|
||
|
|
|
||
|
|
ctx.setIsConnectionPastTcpDial?.(false);
|
||
|
|
if (jumpHosts.length > 0) {
|
||
|
|
ctx.setChainProgress({
|
||
|
|
currentHop: 1,
|
||
|
|
totalHops,
|
||
|
|
currentHostLabel:
|
||
|
|
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
|
||
|
|
connectionPhase: 'connecting',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
{
|
||
|
|
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
|
||
|
|
// P1: Only process events for this session
|
||
|
|
if (sid !== ctx.sessionId) return;
|
||
|
|
// Disconnect/reconnect can leave two SSH starts sharing this UI
|
||
|
|
// sessionId; ignore progress from the superseded boot attempt.
|
||
|
|
if (!isCurrentAttempt()) return;
|
||
|
|
|
||
|
|
// P3: Only show chain progress UI for multi-hop connections
|
||
|
|
if (total > 1) {
|
||
|
|
ctx.setChainProgress({
|
||
|
|
currentHop: hop,
|
||
|
|
totalHops: total,
|
||
|
|
currentHostLabel: label,
|
||
|
|
connectionPhase: status,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build human-readable log line
|
||
|
|
let logLine: string;
|
||
|
|
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
|
||
|
|
|
||
|
|
switch (status) {
|
||
|
|
case 'connecting':
|
||
|
|
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
|
||
|
|
break;
|
||
|
|
case 'tcp-connected':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.tcpConnected", "TCP connected")}`;
|
||
|
|
break;
|
||
|
|
case 'authenticating':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
|
||
|
|
break;
|
||
|
|
case 'auth-attempt':
|
||
|
|
if (error?.endsWith('rejected')) {
|
||
|
|
logLine = `${prefix}${label} - ✗ ${error}`;
|
||
|
|
} else if (error === 'all methods exhausted') {
|
||
|
|
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
|
||
|
|
} else if (error === 'waiting for user input...' || error === 'user responded') {
|
||
|
|
logLine = `${prefix}${label} - ${error}`;
|
||
|
|
} else {
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case 'authenticated':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
|
||
|
|
break;
|
||
|
|
case 'connected':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
|
||
|
|
break;
|
||
|
|
case 'forwarding':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
|
||
|
|
break;
|
||
|
|
case 'shell':
|
||
|
|
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
|
||
|
|
break;
|
||
|
|
case 'error':
|
||
|
|
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (status === 'connecting' || status === 'forwarding') {
|
||
|
|
ctx.setIsConnectionPastTcpDial?.(false);
|
||
|
|
}
|
||
|
|
if (status === 'auth-attempt' && error === 'waiting for user input...') {
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(true);
|
||
|
|
} else if (status === 'auth-attempt' && error === 'user responded') {
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
} else if (status === 'authenticated' || status === 'connected' || status === 'shell' || status === 'error') {
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
}
|
||
|
|
if (hasConnectionPassedTcpDial(status)) {
|
||
|
|
ctx.setIsConnectionPastTcpDial?.(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.setProgressLogs((prev) => [...prev, logLine]);
|
||
|
|
const hopProgress = resolveHopConnectionProgress(hop, total);
|
||
|
|
ctx.setProgressValue((prev) => advanceMonotonicConnectionProgress(prev, hopProgress));
|
||
|
|
});
|
||
|
|
if (unsub) unsubscribeChainProgress = unsub;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||
|
|
|
||
|
|
const authMethod = resolvedAuth.authMethod;
|
||
|
|
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
|
||
|
|
const targetReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
|
||
|
|
const targetIdentityFilePaths = authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: targetReferenceKeyPath
|
||
|
|
? [targetReferenceKeyPath]
|
||
|
|
: allowsLocalIdentityFallback
|
||
|
|
? ctx.host.identityFilePaths
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
const startAttempt = async (attempt: {
|
||
|
|
password?: string;
|
||
|
|
key?: SSHKey;
|
||
|
|
useIdentityFiles?: boolean;
|
||
|
|
useSshAgent?: boolean;
|
||
|
|
}): Promise<string> => {
|
||
|
|
// Reconnect supersedes a Copy/Split intent that was still waiting for credentials.
|
||
|
|
const sourceSessionId = ctx.requireFreshConnectionOnReconnectRef?.current
|
||
|
|
? undefined
|
||
|
|
: ctx.reuseConnectionFromSessionIdRef?.current;
|
||
|
|
if (ctx.reuseConnectionFromSessionIdRef) {
|
||
|
|
ctx.reuseConnectionFromSessionIdRef.current = undefined;
|
||
|
|
}
|
||
|
|
if (sourceSessionId) {
|
||
|
|
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||
|
|
ctx.reuseConnectionSourceAttemptedRef.current = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
ctx.setConnectionReuseAttemptSourceId?.(sourceSessionId);
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
ctx.setIsConnectionPastTcpDial?.(false);
|
||
|
|
// Resolve keepalive per-host: a host can opt into its own values
|
||
|
|
// (e.g. set interval=0 on an embedded device whose SSH stack
|
||
|
|
// doesn't reply to keepalive@openssh.com) while everything else
|
||
|
|
// inherits the cloud-friendly global setting.
|
||
|
|
const keepalive = resolveHostKeepalive(
|
||
|
|
ctx.host,
|
||
|
|
globalTerminalSettings,
|
||
|
|
);
|
||
|
|
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
|
||
|
|
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
|
||
|
|
const startedSessionId = await ctx.terminalBackend.startSSHSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
hostLabel: ctx.host.label,
|
||
|
|
hostname: ctx.host.hostname,
|
||
|
|
hostId: ctx.host.id,
|
||
|
|
username: effectiveUsername,
|
||
|
|
authMethod,
|
||
|
|
requiresMfa: !!ctx.host.requiresMfa,
|
||
|
|
port: ctx.host.port || 22,
|
||
|
|
password: attempt.password,
|
||
|
|
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||
|
|
certificate: attempt.key?.certificate,
|
||
|
|
publicKey: attempt.key?.publicKey,
|
||
|
|
keyId: attempt.key?.id,
|
||
|
|
keySource: attempt.key?.source,
|
||
|
|
passphrase: attempt.key
|
||
|
|
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||
|
|
: undefined,
|
||
|
|
agentForwarding: ctx.host.agentForwarding,
|
||
|
|
x11Forwarding: ctx.host.x11Forwarding,
|
||
|
|
x11Display: ctx.terminalSettings?.x11Display,
|
||
|
|
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||
|
|
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
|
||
|
|
algorithmOverrides: ctx.host.algorithms,
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
charset: ctx.host.charset,
|
||
|
|
// Persist for session-backed SFTP opens (AI tools / clipboard paste).
|
||
|
|
sftpFileProtocol: ctx.host.sftpFileProtocol || "auto",
|
||
|
|
env: termEnv,
|
||
|
|
proxy: proxyConfig,
|
||
|
|
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||
|
|
keepaliveInterval: keepalive.interval,
|
||
|
|
keepaliveCountMax: keepalive.countMax,
|
||
|
|
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||
|
|
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||
|
|
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||
|
|
bootEpoch,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
|
||
|
|
identityFilePaths: attempt.useIdentityFiles ? targetIdentityFilePaths : undefined,
|
||
|
|
...resolveBridgeSshAgentAuth(
|
||
|
|
attempt.useSshAgent === false
|
||
|
|
? { ...ctx.host, useSshAgent: false }
|
||
|
|
: ctx.host,
|
||
|
|
attempt.key,
|
||
|
|
authMethod,
|
||
|
|
),
|
||
|
|
knownHosts: ctx.knownHosts,
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
// Ask the bridge to reuse the source tab's authenticated connection
|
||
|
|
// (issue #1204). Only honored on the very first connect attempt; the
|
||
|
|
// bridge silently falls back to a fresh connection if the source is
|
||
|
|
// gone, so reconnect/retry after the source closed still works.
|
||
|
|
sourceSessionId,
|
||
|
|
// Only an explicit Copy/Split may share an existing login. Ordinary
|
||
|
|
// opens and reconnects must authenticate again to refresh remote groups.
|
||
|
|
reuseTransport: sourceSessionId ? undefined : false,
|
||
|
|
skipShellPidDiscovery: ctx.isNetworkDevice === true,
|
||
|
|
});
|
||
|
|
if (!requiresFreshSshConnection) {
|
||
|
|
ctx.onConnectAutomationSnapshotCommitted?.();
|
||
|
|
}
|
||
|
|
if (ctx.reuseConnectionSourceAttemptedRef) {
|
||
|
|
ctx.reuseConnectionSourceAttemptedRef.current = false;
|
||
|
|
}
|
||
|
|
return startedSessionId;
|
||
|
|
};
|
||
|
|
|
||
|
|
let id: string;
|
||
|
|
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
|
||
|
|
const usesSystemAgent = resolveBridgeSshAgentAuth(ctx.host, key, authMethod).useSshAgent === true;
|
||
|
|
const hasKeyMaterial = usesSystemAgent || (
|
||
|
|
(!!sanitizeCredentialValue(key?.privateKey) || !!targetIdentityFilePaths?.length)
|
||
|
|
&& (authMethod !== 'password' || ctx.host.useSshAgent === true)
|
||
|
|
);
|
||
|
|
const hasPassword = !!effectivePassword;
|
||
|
|
|
||
|
|
const needsCredentialReentry =
|
||
|
|
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||
|
|
(authMethod !== "password" && authMethod !== "auto" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||
|
|
|
||
|
|
if (needsCredentialReentry) {
|
||
|
|
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||
|
|
ctx.setError(null);
|
||
|
|
ctx.setNeedsAuth(true);
|
||
|
|
ctx.setAuthRetryMessage(
|
||
|
|
tr(
|
||
|
|
"terminal.auth.credentialsUnavailable",
|
||
|
|
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||
|
|
),
|
||
|
|
);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setProgressLogs((prev) => [
|
||
|
|
...prev,
|
||
|
|
tr(
|
||
|
|
"terminal.auth.credentialsUnavailable",
|
||
|
|
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||
|
|
),
|
||
|
|
]);
|
||
|
|
ctx.setStatus("connecting");
|
||
|
|
ctx.setChainProgress(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
|
||
|
|
ctx.setProgressLogs((prev) => [
|
||
|
|
...prev,
|
||
|
|
tr(
|
||
|
|
"terminal.auth.keyUnavailableFallbackPassword",
|
||
|
|
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
|
||
|
|
),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
if (hasKeyMaterial) {
|
||
|
|
try {
|
||
|
|
id = await startAttempt({ key, password: hasPassword ? effectivePassword : undefined, useIdentityFiles: true });
|
||
|
|
} catch (err) {
|
||
|
|
if (isAuthError(err) && hasPassword) {
|
||
|
|
// Disconnect/reconnect may have invalidated this boot; do not
|
||
|
|
// launch a password fallback that cannot be cleaned up yet.
|
||
|
|
if (!isCurrentAttempt()) throw err;
|
||
|
|
ctx.setProgressLogs((prev) => [
|
||
|
|
...prev,
|
||
|
|
"Key auth failed. Trying password...",
|
||
|
|
]);
|
||
|
|
id = await startAttempt({ password: effectivePassword, useSshAgent: false });
|
||
|
|
} else {
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
id = await startAttempt({ password: effectivePassword });
|
||
|
|
}
|
||
|
|
|
||
|
|
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||
|
|
// A superseded start may still resolve after reconnect; do not clear the
|
||
|
|
// replacement's MFA wait / connection-timeout state, and do not close the
|
||
|
|
// shared sessionId while the newer boot is active.
|
||
|
|
if (!isCurrentAttempt()) {
|
||
|
|
closeOrphanBackendSession(ctx, id, { bootEpoch });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onConnected: () => ctx.setChainProgress(null),
|
||
|
|
onExitMessage: (evt) =>
|
||
|
|
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||
|
|
})) {
|
||
|
|
// Only the current attempt may clear UI; a stale attach must not
|
||
|
|
// disconnect a newer reconnect that already re-armed boot.
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
consumeRestoreCwdIntent(term, id);
|
||
|
|
scheduleStartupCommand(ctx, term, id);
|
||
|
|
|
||
|
|
// Run OS detection only after successful connection. Mint a fresh
|
||
|
|
// token for this specific connection attempt and register it as
|
||
|
|
// the current one for this sessionId slot; any previous timer
|
||
|
|
// scheduled against an earlier token will see the replacement
|
||
|
|
// and bail out. The detection function re-checks the token after
|
||
|
|
// every async await so a reconnect mid-probe is also caught.
|
||
|
|
{
|
||
|
|
const connectionToken = registerConnectionToken(id);
|
||
|
|
setTimeout(() => {
|
||
|
|
if (!isConnectionTokenCurrent(id, connectionToken)) return;
|
||
|
|
void runDistroDetection(ctx, id, connectionToken);
|
||
|
|
}, 600);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
// Always drop this attempt's progress listener; only the current
|
||
|
|
// boot may reset shared reconnect/UI state that a replacement owns.
|
||
|
|
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
ctx.setChainProgress(null);
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
ctx.setIsConnectionPastTcpDial?.(false);
|
||
|
|
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
const authError = isAuthError(err);
|
||
|
|
|
||
|
|
if (isJumpHostAuthError(err, message)) {
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
} else if (authError) {
|
||
|
|
ctx.setError(null);
|
||
|
|
ctx.setNeedsAuth(true);
|
||
|
|
ctx.setAuthRetryMessage(
|
||
|
|
tr(
|
||
|
|
"terminal.auth.retryMessage",
|
||
|
|
"Authentication failed. Please check your credentials and try again.",
|
||
|
|
),
|
||
|
|
);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setProgressLogs((prev) => [
|
||
|
|
...prev,
|
||
|
|
tr("terminal.auth.retryLog", "Authentication failed. Please try again."),
|
||
|
|
]);
|
||
|
|
ctx.setStatus("connecting");
|
||
|
|
} else {
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startTelnet = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.terminalBackend.telnetAvailable()) {
|
||
|
|
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||
|
|
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findMissingProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
const message = formatMissingProxyIdentityMessage(ctx.host.label || ctx.host.hostname);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findIncompleteProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
const message = formatIncompleteProxyIdentityMessage(ctx.host.label || ctx.host.hostname);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hasUsableProxyConfig(ctx.host.proxyConfig)) {
|
||
|
|
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let disposeAutoLoginComplete: (() => void) | undefined;
|
||
|
|
let disposeAutoLoginCancelled: (() => void) | undefined;
|
||
|
|
let cancelPendingStartupCommand: (() => void) | undefined;
|
||
|
|
const disposeAutoLoginListener = () => {
|
||
|
|
disposeAutoLoginComplete?.();
|
||
|
|
disposeAutoLoginComplete = undefined;
|
||
|
|
};
|
||
|
|
const disposeAutoLoginCancelListener = () => {
|
||
|
|
disposeAutoLoginCancelled?.();
|
||
|
|
disposeAutoLoginCancelled = undefined;
|
||
|
|
};
|
||
|
|
const cleanupTelnetStartupWait = () => {
|
||
|
|
disposeAutoLoginListener();
|
||
|
|
disposeAutoLoginCancelListener();
|
||
|
|
cancelPendingStartupCommand?.();
|
||
|
|
cancelPendingStartupCommand = undefined;
|
||
|
|
};
|
||
|
|
const cleanupTelnetSession = () => {
|
||
|
|
cleanupTelnetStartupWait();
|
||
|
|
clearTelnetEchoMode();
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||
|
|
const telnetIdentity = ctx.host.telnetIdentityId
|
||
|
|
? ctx.identities.find((identity) => identity.id === ctx.host.telnetIdentityId)
|
||
|
|
: undefined;
|
||
|
|
if (ctx.host.telnetIdentityId && !telnetIdentity) {
|
||
|
|
const message = "Telnet identity is missing. Open host settings and select a valid identity.";
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (telnetIdentity && (!telnetIdentity.username?.trim() || telnetIdentity.password === undefined)) {
|
||
|
|
const message = "Telnet identity must include a username and password. Open host settings and select a password identity.";
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const telnetUsername = telnetIdentity
|
||
|
|
? telnetIdentity.username?.trim()
|
||
|
|
: resolveTelnetUsername(ctx.host);
|
||
|
|
const rawTelnetPassword = telnetIdentity
|
||
|
|
? telnetIdentity.password
|
||
|
|
: resolveTelnetPassword(ctx.host);
|
||
|
|
const telnetPassword = sanitizeCredentialValue(rawTelnetPassword);
|
||
|
|
const hasTelnetPasswordForAutoLogin = rawTelnetPassword !== undefined;
|
||
|
|
if (isEncryptedCredentialPlaceholder(rawTelnetPassword)) {
|
||
|
|
const message = tr(
|
||
|
|
"terminal.auth.credentialsUnavailable",
|
||
|
|
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||
|
|
);
|
||
|
|
ctx.setNeedsAuth(false);
|
||
|
|
ctx.setAuthRetryMessage(null);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const commandToRun = resolveStartupCommand(ctx);
|
||
|
|
const waitsForAutoLogin = Boolean(
|
||
|
|
commandToRun &&
|
||
|
|
(telnetUsername || hasTelnetPasswordForAutoLogin) &&
|
||
|
|
ctx.terminalBackend.onTelnetAutoLoginComplete,
|
||
|
|
);
|
||
|
|
let telnetSessionId = ctx.sessionId;
|
||
|
|
if (waitsForAutoLogin) {
|
||
|
|
disposeAutoLoginComplete = ctx.terminalBackend.onTelnetAutoLoginComplete?.(
|
||
|
|
ctx.sessionId,
|
||
|
|
(evt) => {
|
||
|
|
if (
|
||
|
|
Number.isFinite(bootEpoch)
|
||
|
|
&& Number.isFinite(evt?.bootEpoch)
|
||
|
|
&& evt.bootEpoch !== bootEpoch
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
disposeAutoLoginListener();
|
||
|
|
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
|
||
|
|
cancelPendingStartupCommand = undefined;
|
||
|
|
disposeAutoLoginCancelListener();
|
||
|
|
});
|
||
|
|
},
|
||
|
|
);
|
||
|
|
disposeAutoLoginCancelled = ctx.terminalBackend.onTelnetAutoLoginCancelled?.(
|
||
|
|
ctx.sessionId,
|
||
|
|
(evt) => {
|
||
|
|
if (
|
||
|
|
Number.isFinite(bootEpoch)
|
||
|
|
&& Number.isFinite(evt?.bootEpoch)
|
||
|
|
&& evt.bootEpoch !== bootEpoch
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cleanupTelnetStartupWait();
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
attachTelnetEchoMode(ctx.sessionId);
|
||
|
|
const id = await ctx.terminalBackend.startTelnetSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
protocol: ctx.host.protocol,
|
||
|
|
hostname: ctx.host.hostname,
|
||
|
|
port: resolveTelnetPort(ctx.host),
|
||
|
|
username: telnetUsername,
|
||
|
|
password: telnetPassword,
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
charset: ctx.host.charset,
|
||
|
|
env: telnetEnv,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
bootEpoch,
|
||
|
|
});
|
||
|
|
telnetSessionId = id;
|
||
|
|
if (id !== ctx.sessionId) {
|
||
|
|
attachTelnetEchoMode(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onExitMessage: (evt) =>
|
||
|
|
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||
|
|
onExit: cleanupTelnetSession,
|
||
|
|
})) {
|
||
|
|
cleanupTelnetSession();
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Many telnet endpoints (especially no-auth devices) stay silent until
|
||
|
|
// the client sends data. Mark connected once the socket session is
|
||
|
|
// attached so the connection overlay dismisses and keyboard input works
|
||
|
|
// (issue #1632).
|
||
|
|
ctx.updateStatus("connected");
|
||
|
|
ctx.setProgressValue(100);
|
||
|
|
|
||
|
|
if (waitsForAutoLogin) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
if (message.includes(TELNET_SESSION_REPLACED_ERROR)) {
|
||
|
|
cleanupTelnetStartupWait();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
cleanupTelnetSession();
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start Telnet: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startMosh = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.terminalBackend.moshAvailable()) {
|
||
|
|
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Hoisted so the catch path can dispose a ready subscription registered
|
||
|
|
// before startMoshSession resolves.
|
||
|
|
let disposeMoshReady: (() => void) | undefined;
|
||
|
|
let cancelPendingStartupCommand: (() => void) | undefined;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const stopMosh = (message: string) => {
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
};
|
||
|
|
|
||
|
|
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||
|
|
stopMosh(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findMissingProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
stopMosh(formatMissingProxyIdentityMessage(ctx.host.label || ctx.host.hostname));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findIncompleteProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
stopMosh(formatIncompleteProxyIdentityMessage(ctx.host.label || ctx.host.hostname));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasConfiguredJumpHostChain =
|
||
|
|
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
|
||
|
|
ctx.resolvedChainHosts.length > 0;
|
||
|
|
if (hasConfiguredJumpHostChain) {
|
||
|
|
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||
|
|
if (unresolvedJumpProxyHost) {
|
||
|
|
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasConfiguredProxy =
|
||
|
|
hasUsableProxyConfig(ctx.host.proxyConfig) ||
|
||
|
|
ctx.resolvedChainHosts.some((jumpHost) => hasUsableProxyConfig(jumpHost.proxyConfig));
|
||
|
|
if (hasConfiguredProxy) {
|
||
|
|
stopMosh("Mosh does not support proxy connections. Use SSH for this host or remove the proxy from this connection.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const pendingAuth = ctx.pendingAuthRef.current;
|
||
|
|
const pendingKeyHydration = hydrateConnectKeysIfNeeded(
|
||
|
|
ctx.keys,
|
||
|
|
collectConnectKeyIds(ctx.host, ctx.resolvedChainHosts, ctx.identities, pendingAuth),
|
||
|
|
);
|
||
|
|
const { keys, unreadableKeyIds } = pendingKeyHydration
|
||
|
|
? await pendingKeyHydration
|
||
|
|
: { keys: ctx.keys, unreadableKeyIds: new Set<string>() };
|
||
|
|
if (pendingKeyHydration && !isCurrentAttempt()) return;
|
||
|
|
const resolvedAuth = resolveHostAuth({
|
||
|
|
host: ctx.host,
|
||
|
|
keys,
|
||
|
|
identities: ctx.identities,
|
||
|
|
override: pendingAuth
|
||
|
|
? {
|
||
|
|
authMethod: pendingAuth.authMethod,
|
||
|
|
username: pendingAuth.username,
|
||
|
|
password: pendingAuth.password,
|
||
|
|
keyId: pendingAuth.keyId,
|
||
|
|
passphrase: pendingAuth.passphrase,
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
});
|
||
|
|
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||
|
|
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||
|
|
const authMethod = resolvedAuth.authMethod;
|
||
|
|
const key = authMethod === "password" ? undefined : resolvedAuth.key;
|
||
|
|
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||
|
|
const hasEncryptedPrimaryKey = Boolean(
|
||
|
|
key && (unreadableKeyIds.has(key.id) || isEncryptedCredentialPlaceholder(key.privateKey)),
|
||
|
|
);
|
||
|
|
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
|
||
|
|
const moshReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
|
||
|
|
const moshIdentityFilePaths = authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: moshReferenceKeyPath
|
||
|
|
? [moshReferenceKeyPath]
|
||
|
|
: allowsLocalIdentityFallback
|
||
|
|
? ctx.host.identityFilePaths
|
||
|
|
: undefined;
|
||
|
|
const moshAgentAuth = resolveBridgeSshAgentAuth(ctx.host, key, authMethod);
|
||
|
|
const usesSystemAgent = moshAgentAuth.useSshAgent === true;
|
||
|
|
const hasKeyMaterial = usesSystemAgent || (
|
||
|
|
(!!sanitizeCredentialValue(key?.privateKey) || !!moshIdentityFilePaths?.length)
|
||
|
|
&& (authMethod !== "password" || ctx.host.useSshAgent === true)
|
||
|
|
);
|
||
|
|
const hasPassword = !!effectivePassword;
|
||
|
|
const needsCredentialReentry =
|
||
|
|
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||
|
|
(authMethod !== "password" && authMethod !== "auto" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||
|
|
|
||
|
|
if (needsCredentialReentry) {
|
||
|
|
ctx.setError(null);
|
||
|
|
ctx.setNeedsAuth(true);
|
||
|
|
ctx.setAuthRetryMessage(
|
||
|
|
tr(
|
||
|
|
"terminal.auth.credentialsUnavailable",
|
||
|
|
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||
|
|
),
|
||
|
|
);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setStatus("connecting");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||
|
|
|
||
|
|
// Defer startup commands until mosh-client is ready. The backend
|
||
|
|
// handshake uses an ephemeral SSH PTY first; writing too early lands
|
||
|
|
// input on that PTY and is lost on the swap (issue #2199).
|
||
|
|
//
|
||
|
|
// Keep the progress overlay until mosh-client is ready. The attachment
|
||
|
|
// path still dismisses it early for an interactive password/OTP prompt
|
||
|
|
// so the user can type into the terminal.
|
||
|
|
//
|
||
|
|
// Subscribe BEFORE startMoshSession: a fast passwordless handshake can
|
||
|
|
// emit ready before the await returns, and the event is not replayed.
|
||
|
|
let sessionAttached = false;
|
||
|
|
let moshReadyFired = false;
|
||
|
|
let attachedSessionId = ctx.sessionId;
|
||
|
|
const cleanupMoshStartupWait = () => {
|
||
|
|
disposeMoshReady?.();
|
||
|
|
disposeMoshReady = undefined;
|
||
|
|
cancelPendingStartupCommand?.();
|
||
|
|
cancelPendingStartupCommand = undefined;
|
||
|
|
};
|
||
|
|
const detectMoshSystem = () => {
|
||
|
|
if (!isCurrentAttempt()) return;
|
||
|
|
const token = registerConnectionToken(attachedSessionId);
|
||
|
|
void runDistroDetection(ctx, attachedSessionId, token);
|
||
|
|
};
|
||
|
|
const runMoshStartup = () => {
|
||
|
|
detectMoshSystem();
|
||
|
|
disposeMoshReady?.();
|
||
|
|
disposeMoshReady = undefined;
|
||
|
|
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||
|
|
if (!ctx.hasConnectedRef.current) ctx.updateStatus("connected");
|
||
|
|
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, attachedSessionId, () => {
|
||
|
|
cancelPendingStartupCommand = undefined;
|
||
|
|
});
|
||
|
|
};
|
||
|
|
const onMoshReady = (evt?: { sessionId: string; bootEpoch?: number }) => {
|
||
|
|
if (
|
||
|
|
Number.isFinite(bootEpoch)
|
||
|
|
&& Number.isFinite(evt?.bootEpoch)
|
||
|
|
&& evt.bootEpoch !== bootEpoch
|
||
|
|
) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
moshReadyFired = true;
|
||
|
|
if (sessionAttached) {
|
||
|
|
runMoshStartup();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (ctx.terminalBackend.onMoshSessionReady) {
|
||
|
|
disposeMoshReady = ctx.terminalBackend.onMoshSessionReady(ctx.sessionId, onMoshReady);
|
||
|
|
}
|
||
|
|
|
||
|
|
const id = await ctx.terminalBackend.startMoshSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
hostname: ctx.host.hostname,
|
||
|
|
username: resolvedAuth.username || "root",
|
||
|
|
authMethod,
|
||
|
|
requiresMfa: !!ctx.host.requiresMfa,
|
||
|
|
password: effectivePassword,
|
||
|
|
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||
|
|
certificate: key?.certificate,
|
||
|
|
keyId: key?.id,
|
||
|
|
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||
|
|
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
|
||
|
|
: undefined,
|
||
|
|
identityFilePaths: moshIdentityFilePaths,
|
||
|
|
...moshAgentAuth,
|
||
|
|
port: ctx.host.port || 22,
|
||
|
|
moshServerPath: ctx.host.moshServerPath,
|
||
|
|
agentForwarding: ctx.host.agentForwarding,
|
||
|
|
// Forwarded for the host-info stats companion SSH connection (#1198):
|
||
|
|
// Mosh's own handshake uses the system ssh (which reads ~/.ssh/config),
|
||
|
|
// but Netcatty's ssh2 companion needs these to match the host's
|
||
|
|
// negotiation on legacy / ECDSA-restricted servers.
|
||
|
|
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||
|
|
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
|
||
|
|
algorithmOverrides: ctx.host.algorithms,
|
||
|
|
// Lets the stats companion verify the host key before sending a saved
|
||
|
|
// password (#1198), so it never discloses it to an unvetted host.
|
||
|
|
knownHosts: ctx.knownHosts,
|
||
|
|
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
charset: ctx.host.charset,
|
||
|
|
env: moshEnv,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
bootEpoch,
|
||
|
|
});
|
||
|
|
attachedSessionId = id;
|
||
|
|
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onExitMessage: (evt) =>
|
||
|
|
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||
|
|
// Real backend exit only — do not chain onto disposeExitRef, because
|
||
|
|
// hibernate detaches exit listeners without closing the session and
|
||
|
|
// would otherwise cancel a still-pending startup command.
|
||
|
|
onExit: cleanupMoshStartupWait,
|
||
|
|
deferConnectionDuringMoshHandshake: Boolean(ctx.terminalBackend.onMoshSessionReady),
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||
|
|
})) {
|
||
|
|
cleanupMoshStartupWait();
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
sessionAttached = true;
|
||
|
|
|
||
|
|
if (ctx.terminalBackend.onMoshSessionReady) {
|
||
|
|
if (moshReadyFired) {
|
||
|
|
runMoshStartup();
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Older bridges without the ready event: the start call completed the handshake.
|
||
|
|
detectMoshSystem();
|
||
|
|
scheduleStartupCommand(ctx, term, id);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
// Drop any pre-start ready subscription if handshake never attached.
|
||
|
|
disposeMoshReady?.();
|
||
|
|
disposeMoshReady = undefined;
|
||
|
|
cancelPendingStartupCommand?.();
|
||
|
|
cancelPendingStartupCommand = undefined;
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start Mosh: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startEt = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.terminalBackend.etAvailable()) {
|
||
|
|
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const stopEt = (message: string) => {
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
};
|
||
|
|
|
||
|
|
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||
|
|
stopEt(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findMissingProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
stopEt(formatMissingProxyIdentityMessage(ctx.host.label || ctx.host.hostname));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (findIncompleteProxyIdentityId(ctx.host.proxyConfig, ctx.identities)) {
|
||
|
|
stopEt(formatIncompleteProxyIdentityMessage(ctx.host.label || ctx.host.hostname));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (hasUsableProxyConfig(ctx.host.proxyConfig)) {
|
||
|
|
stopEt(tr(
|
||
|
|
"terminal.et.proxyUnsupported",
|
||
|
|
"EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.",
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Enforce the "at most one jump host" rule on the *configured* chain, not
|
||
|
|
// just the resolved list. A second hop whose host ID fails to resolve
|
||
|
|
// would otherwise slip past a resolved-length check and silently drop to
|
||
|
|
// a single (or zero) hop.
|
||
|
|
const configuredChainHostCount = ctx.host.hostChain?.hostIds?.length ?? 0;
|
||
|
|
if (configuredChainHostCount > 1 || ctx.resolvedChainHosts.length > 1) {
|
||
|
|
stopEt(tr(
|
||
|
|
"terminal.et.multiJumpUnsupported",
|
||
|
|
"EternalTerminal currently supports at most one jump host in Netcatty.",
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mirror startSSH: if a configured jump host could not be resolved (its
|
||
|
|
// host ID is missing/invalid), fail loudly instead of silently falling
|
||
|
|
// back to a direct connection that may reach the wrong target.
|
||
|
|
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
|
||
|
|
if (missingChainHostIds.length > 0) {
|
||
|
|
const base = tr(
|
||
|
|
"terminal.auth.jumpHostMissing",
|
||
|
|
"A configured jump host is missing. Open host settings and repair the jump host chain.",
|
||
|
|
);
|
||
|
|
const suffix = missingChainHostIds.length > 2
|
||
|
|
? ` +${missingChainHostIds.length - 2}`
|
||
|
|
: "";
|
||
|
|
stopEt(`${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||
|
|
if (unresolvedJumpProxyHost) {
|
||
|
|
stopEt(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const unresolvedJumpProxyIdentityHost = ctx.resolvedChainHosts.find((jumpHost) =>
|
||
|
|
findMissingProxyIdentityId(jumpHost.proxyConfig, ctx.identities),
|
||
|
|
);
|
||
|
|
if (unresolvedJumpProxyIdentityHost) {
|
||
|
|
stopEt(formatMissingProxyIdentityMessage(
|
||
|
|
unresolvedJumpProxyIdentityHost.label || unresolvedJumpProxyIdentityHost.hostname,
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const incompleteJumpProxyIdentityHost = ctx.resolvedChainHosts.find((jumpHost) =>
|
||
|
|
findIncompleteProxyIdentityId(jumpHost.proxyConfig, ctx.identities),
|
||
|
|
);
|
||
|
|
if (incompleteJumpProxyIdentityHost) {
|
||
|
|
stopEt(formatIncompleteProxyIdentityMessage(
|
||
|
|
incompleteJumpProxyIdentityHost.label || incompleteJumpProxyIdentityHost.hostname,
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const pendingAuth = ctx.pendingAuthRef.current;
|
||
|
|
const pendingKeyHydration = hydrateConnectKeysIfNeeded(
|
||
|
|
ctx.keys,
|
||
|
|
collectConnectKeyIds(ctx.host, ctx.resolvedChainHosts, ctx.identities, pendingAuth),
|
||
|
|
);
|
||
|
|
const { keys, unreadableKeyIds } = pendingKeyHydration
|
||
|
|
? await pendingKeyHydration
|
||
|
|
: { keys: ctx.keys, unreadableKeyIds: new Set<string>() };
|
||
|
|
if (pendingKeyHydration && !isCurrentAttempt()) return;
|
||
|
|
const resolvedAuth = resolveHostAuth({
|
||
|
|
host: ctx.host,
|
||
|
|
keys,
|
||
|
|
identities: ctx.identities,
|
||
|
|
override: pendingAuth
|
||
|
|
? {
|
||
|
|
authMethod: pendingAuth.authMethod,
|
||
|
|
username: pendingAuth.username,
|
||
|
|
password: pendingAuth.password,
|
||
|
|
keyId: pendingAuth.keyId,
|
||
|
|
passphrase: pendingAuth.passphrase,
|
||
|
|
}
|
||
|
|
: null,
|
||
|
|
});
|
||
|
|
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||
|
|
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||
|
|
const authMethod = resolvedAuth.authMethod;
|
||
|
|
const key = authMethod === "password" ? undefined : resolvedAuth.key;
|
||
|
|
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||
|
|
const hasEncryptedPrimaryKey = Boolean(
|
||
|
|
key && (unreadableKeyIds.has(key.id) || isEncryptedCredentialPlaceholder(key.privateKey)),
|
||
|
|
);
|
||
|
|
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
|
||
|
|
const etReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
|
||
|
|
const etIdentityFilePaths = authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: etReferenceKeyPath
|
||
|
|
? [etReferenceKeyPath]
|
||
|
|
: allowsLocalIdentityFallback
|
||
|
|
? ctx.host.identityFilePaths
|
||
|
|
: undefined;
|
||
|
|
const etAgentAuth = resolveBridgeSshAgentAuth(ctx.host, key, authMethod);
|
||
|
|
const usesSystemAgent = etAgentAuth.useSshAgent === true;
|
||
|
|
const hasKeyMaterial = usesSystemAgent || (
|
||
|
|
(!!sanitizeCredentialValue(key?.privateKey) || !!etIdentityFilePaths?.length)
|
||
|
|
&& (authMethod !== "password" || ctx.host.useSshAgent === true)
|
||
|
|
);
|
||
|
|
const hasPassword = !!effectivePassword;
|
||
|
|
const needsCredentialReentry =
|
||
|
|
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||
|
|
(authMethod !== "password" && authMethod !== "auto" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||
|
|
|
||
|
|
if (needsCredentialReentry) {
|
||
|
|
ctx.setError(null);
|
||
|
|
ctx.setNeedsAuth(true);
|
||
|
|
ctx.setAuthRetryMessage(
|
||
|
|
tr(
|
||
|
|
"terminal.auth.credentialsUnavailable",
|
||
|
|
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||
|
|
),
|
||
|
|
);
|
||
|
|
ctx.setAuthPassword("");
|
||
|
|
ctx.setStatus("connecting");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||
|
|
const unsupportedJumpProxies: string[] = [];
|
||
|
|
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||
|
|
const jumpAuth = resolveHostAuth({
|
||
|
|
host: jumpHost,
|
||
|
|
keys,
|
||
|
|
identities: ctx.identities,
|
||
|
|
});
|
||
|
|
const jumpKey = jumpAuth.key;
|
||
|
|
const rawJumpPassword = jumpAuth.password;
|
||
|
|
const rawJumpPrivateKey = jumpKey?.privateKey;
|
||
|
|
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
|
||
|
|
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||
|
|
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||
|
|
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||
|
|
|
||
|
|
if (hasUsableProxyConfig(jumpHost.proxyConfig)) {
|
||
|
|
unsupportedJumpProxies.push(jumpHost.label || jumpHost.hostname);
|
||
|
|
}
|
||
|
|
|
||
|
|
const hasEncryptedJumpCredential =
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||
|
|
Boolean(jumpKey && unreadableKeyIds.has(jumpKey.id)) ||
|
||
|
|
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||
|
|
const jumpAgentAuth = resolveBridgeSshAgentAuth(jumpHost, jumpKey, jumpAuth.authMethod);
|
||
|
|
if (
|
||
|
|
jumpAuth.authMethod !== "auto"
|
||
|
|
&& hasEncryptedJumpCredential
|
||
|
|
&& !jumpPassword
|
||
|
|
&& !jumpPrivateKey
|
||
|
|
&& !jumpPassphrase
|
||
|
|
&& !jumpAgentAuth.useSshAgent
|
||
|
|
) {
|
||
|
|
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mirror startSSH: a reference key lives on disk, so forward its path as
|
||
|
|
// an IdentityFile instead of dropping it (privateKey is undefined for
|
||
|
|
// reference keys). Without this, ET jump-host key auth silently falls
|
||
|
|
// back to defaults even when a valid key is selected.
|
||
|
|
const jumpAllowsLocalIdentityFallback = !jumpAuth.keyId;
|
||
|
|
const jumpReferenceKeyPath = jumpAuth.authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: jumpKey?.source === 'reference' ? jumpKey.filePath : undefined;
|
||
|
|
const jumpIdentityFilePaths = jumpAuth.authMethod === "password"
|
||
|
|
? undefined
|
||
|
|
: jumpReferenceKeyPath
|
||
|
|
? [jumpReferenceKeyPath]
|
||
|
|
: jumpAllowsLocalIdentityFallback
|
||
|
|
? jumpHost.identityFilePaths
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
return {
|
||
|
|
hostname: jumpHost.hostname,
|
||
|
|
hostId: jumpHost.id,
|
||
|
|
port: jumpHost.port || 22,
|
||
|
|
// ET server port on this bastion: the bridge tunnels the ET socket to
|
||
|
|
// the jumphost's etserver, so a custom etPort must be forwarded or it
|
||
|
|
// defaults to 2022 and the connection fails.
|
||
|
|
etPort: jumpHost.etPort,
|
||
|
|
username: jumpAuth.username || "root",
|
||
|
|
authMethod: jumpAuth.authMethod,
|
||
|
|
password: jumpPassword,
|
||
|
|
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||
|
|
certificate: jumpKey?.certificate,
|
||
|
|
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
|
||
|
|
keyId: jumpAuth.keyId,
|
||
|
|
keySource: jumpKey?.source,
|
||
|
|
label: jumpHost.label,
|
||
|
|
identityFilePaths: jumpIdentityFilePaths,
|
||
|
|
...jumpAgentAuth,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
if (unsupportedJumpProxies.length > 0) {
|
||
|
|
stopEt(tr(
|
||
|
|
"terminal.et.proxyUnsupported",
|
||
|
|
"EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.",
|
||
|
|
));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (jumpHostsWithUnavailableCredentials.length > 0) {
|
||
|
|
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
|
||
|
|
const suffix = jumpHostsWithUnavailableCredentials.length > 2
|
||
|
|
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
|
||
|
|
: "";
|
||
|
|
const base = tr(
|
||
|
|
"terminal.auth.jumpCredentialsUnavailable",
|
||
|
|
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
|
||
|
|
);
|
||
|
|
stopEt(`${base} (${jumpList}${suffix})`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const etEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||
|
|
const id = await ctx.terminalBackend.startEtSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
hostname: ctx.host.hostname,
|
||
|
|
hostId: ctx.host.id,
|
||
|
|
username: resolvedAuth.username || "root",
|
||
|
|
password: effectivePassword,
|
||
|
|
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||
|
|
certificate: key?.certificate,
|
||
|
|
keyId: key?.id,
|
||
|
|
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||
|
|
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
|
||
|
|
: undefined,
|
||
|
|
authMethod,
|
||
|
|
identityFilePaths: etIdentityFilePaths,
|
||
|
|
...etAgentAuth,
|
||
|
|
port: ctx.host.port || 22,
|
||
|
|
etPort: ctx.host.etPort,
|
||
|
|
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||
|
|
skipEcdsaHostKey: ctx.host.skipEcdsaHostKey,
|
||
|
|
algorithmOverrides: ctx.host.algorithms,
|
||
|
|
knownHosts: ctx.knownHosts,
|
||
|
|
verifyHostKeys: globalTerminalSettings.verifyHostKeys,
|
||
|
|
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||
|
|
agentForwarding: ctx.host.agentForwarding,
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
charset: ctx.host.charset,
|
||
|
|
env: etEnv,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
bootEpoch,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onExitMessage: (evt) =>
|
||
|
|
`\r\n[EternalTerminal session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||
|
|
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
|
||
|
|
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||
|
|
})) {
|
||
|
|
// Only the current attempt may clear UI; a stale attach must not
|
||
|
|
// disconnect a newer reconnect that already re-armed boot.
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
scheduleStartupCommand(ctx, term, id);
|
||
|
|
|
||
|
|
// ET sessions are full remote shells, so run OS detection like SSH for
|
||
|
|
// server stats / distro icons.
|
||
|
|
{
|
||
|
|
const connectionToken = registerConnectionToken(id);
|
||
|
|
setTimeout(() => {
|
||
|
|
if (!isConnectionTokenCurrent(id, connectionToken)) return;
|
||
|
|
void runDistroDetection(ctx, id, connectionToken);
|
||
|
|
}, 600);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start EternalTerminal: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startPluginConnection = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.terminalBackend.pluginConnectionAvailable()) {
|
||
|
|
ctx.setError("Plugin connection bridge unavailable. Please run the desktop build with Plugin Development enabled.");
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[Plugin connection bridge unavailable.]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!isPluginHostProtocol(ctx.host.protocol)) {
|
||
|
|
ctx.setError("Plugin connection protocol is invalid.");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const connection = sanitizePluginConnection(ctx.host.pluginConnection, ctx.host.protocol);
|
||
|
|
if (!connection) {
|
||
|
|
const message = "Plugin connection configuration is missing or invalid. Open host settings and select an installed connection Provider.";
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const requestId = createPluginConnectionRequestId();
|
||
|
|
const startController = new AbortController();
|
||
|
|
let pendingCancelReleased = false;
|
||
|
|
let bootMonitorTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
|
const previousDisposeExit = ctx.disposeExitRef.current;
|
||
|
|
const clearBootMonitor = () => {
|
||
|
|
if (bootMonitorTimer === null) return;
|
||
|
|
clearTimeout(bootMonitorTimer);
|
||
|
|
bootMonitorTimer = null;
|
||
|
|
};
|
||
|
|
const cancelPendingStart = () => {
|
||
|
|
previousDisposeExit?.();
|
||
|
|
if (pendingCancelReleased) return;
|
||
|
|
pendingCancelReleased = true;
|
||
|
|
clearBootMonitor();
|
||
|
|
startController.abort(new DOMException("Plugin connection request was cancelled", "AbortError"));
|
||
|
|
if (ctx.terminalBackend.cancelPluginExtensionRequest) {
|
||
|
|
try {
|
||
|
|
const cancelPromise = Promise.resolve(
|
||
|
|
ctx.terminalBackend.cancelPluginExtensionRequest(requestId),
|
||
|
|
).catch((err) => {
|
||
|
|
logger.warn("Failed to cancel pending plugin connection request", err);
|
||
|
|
});
|
||
|
|
ctx.trackSessionCleanup?.(cancelPromise);
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("Failed to cancel pending plugin connection request", err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const scheduleBootMonitor = () => {
|
||
|
|
if (pendingCancelReleased) return;
|
||
|
|
bootMonitorTimer = setTimeout(() => {
|
||
|
|
bootMonitorTimer = null;
|
||
|
|
if (pendingCancelReleased) return;
|
||
|
|
if (!isCurrentAttempt()) {
|
||
|
|
cancelPendingStart();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
scheduleBootMonitor();
|
||
|
|
}, 50);
|
||
|
|
};
|
||
|
|
// The mount effect aborts its boot controller before the async teardown
|
||
|
|
// runs, so forward that straight to the in-flight extension request.
|
||
|
|
const onBootAborted = () => cancelPendingStart();
|
||
|
|
const releasePendingStartCancellation = () => {
|
||
|
|
options?.signal?.removeEventListener("abort", onBootAborted);
|
||
|
|
if (ctx.disposeExitRef.current === cancelPendingStart) {
|
||
|
|
ctx.disposeExitRef.current = previousDisposeExit;
|
||
|
|
}
|
||
|
|
pendingCancelReleased = true;
|
||
|
|
clearBootMonitor();
|
||
|
|
};
|
||
|
|
ctx.disposeExitRef.current = cancelPendingStart;
|
||
|
|
options?.signal?.addEventListener("abort", onBootAborted, { once: true });
|
||
|
|
scheduleBootMonitor();
|
||
|
|
try {
|
||
|
|
const startPromise = ctx.terminalBackend.startPluginConnection({
|
||
|
|
requestId,
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
protocol: ctx.host.protocol,
|
||
|
|
hostLabel: ctx.host.label,
|
||
|
|
hostname: ctx.host.hostname,
|
||
|
|
providerId: connection.providerId,
|
||
|
|
configuration: connection.configuration,
|
||
|
|
columns: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
...(connection.authenticationProviderId
|
||
|
|
? { authenticationProviderId: connection.authenticationProviderId }
|
||
|
|
: {}),
|
||
|
|
...(connection.credentialId
|
||
|
|
? { credential: { kind: "credential" as const, id: connection.credentialId } }
|
||
|
|
: {}),
|
||
|
|
signal: startController.signal,
|
||
|
|
});
|
||
|
|
// Disconnect may cancel before this settles; reconnect must wait until
|
||
|
|
// finishExternalSession cleared the shared sessionId registration.
|
||
|
|
ctx.trackSessionCleanup?.(startPromise);
|
||
|
|
const opened = await startPromise;
|
||
|
|
releasePendingStartCancellation();
|
||
|
|
if (!isCurrentAttempt()) {
|
||
|
|
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (startController.signal.aborted) {
|
||
|
|
closeOrphanBackendSession(ctx, opened.sessionId, { bootEpoch });
|
||
|
|
abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const id = opened.sessionId;
|
||
|
|
if (opened.diagnostics.length > 0) {
|
||
|
|
ctx.setProgressLogs((previous) => [
|
||
|
|
...previous,
|
||
|
|
...formatPluginDiagnosticLines(opened.diagnostics),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
let startupScheduled = false;
|
||
|
|
const schedulePluginStartup = () => {
|
||
|
|
if (startupScheduled) return;
|
||
|
|
startupScheduled = true;
|
||
|
|
scheduleStartupCommand(ctx, term, id);
|
||
|
|
};
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onExitMessage: formatPluginConnectionExitMessage,
|
||
|
|
requireExplicitConnectionReady: true,
|
||
|
|
onConnected: (meta) => {
|
||
|
|
if (meta?.pluginConnectionReady === true) schedulePluginStartup();
|
||
|
|
},
|
||
|
|
})) {
|
||
|
|
// Only the current attempt may clear UI; a stale attach must not
|
||
|
|
// disconnect a newer reconnect that already re-armed boot.
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (opened.status === "connected") {
|
||
|
|
ctx.updateStatus("connected");
|
||
|
|
schedulePluginStartup();
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
releasePendingStartCancellation();
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[Failed to start plugin connection. See connection details.]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const startLocal = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.terminalBackend.localAvailable()) {
|
||
|
|
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
|
||
|
|
writeTerminalLine(
|
||
|
|
ctx,
|
||
|
|
term,
|
||
|
|
"\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]",
|
||
|
|
);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Per-session shell (from QuickSwitcher discovery or split/copy) takes priority.
|
||
|
|
// The global terminalSettings.localShell may contain a shell ID (e.g., "wsl-ubuntu")
|
||
|
|
// which was already resolved to command+args and stored on the session object by App.tsx.
|
||
|
|
// Only pass shell/shellArgs when we have concrete per-session values;
|
||
|
|
// otherwise omit them so the backend uses its own default shell detection.
|
||
|
|
const sessionShell = ctx.host.localShell;
|
||
|
|
const sessionShellArgs = ctx.host.localShellArgs;
|
||
|
|
const localStartDir = ctx.host.localStartDir || ctx.terminalSettings?.localStartDir;
|
||
|
|
|
||
|
|
const id = await ctx.terminalBackend.startLocalSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
cols: term.cols,
|
||
|
|
rows: term.rows,
|
||
|
|
shell: sessionShell || undefined,
|
||
|
|
shellArgs: sessionShellArgs || undefined,
|
||
|
|
cwd: localStartDir,
|
||
|
|
env: {
|
||
|
|
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||
|
|
},
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
bootEpoch,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!isCurrentAttempt()) {
|
||
|
|
closeOrphanBackendSession(ctx, id, { bootEpoch });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.sessionRef.current = id;
|
||
|
|
const flow = getFlowController(ctx, term);
|
||
|
|
teardownTerminalOutputPipeline(ctx, term, id, flow);
|
||
|
|
flushTerminalWriteCoalescer(term);
|
||
|
|
resetTerminalSyncBlockFilter(term);
|
||
|
|
resetTerminalLineTimestampState(term);
|
||
|
|
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(
|
||
|
|
id,
|
||
|
|
(chunk, meta) => {
|
||
|
|
const pluginPipelineIngressBytes = Number.isFinite(meta?.pluginPipelineIngressBytes)
|
||
|
|
? Math.max(0, Number(meta.pluginPipelineIngressBytes))
|
||
|
|
: chunk.length;
|
||
|
|
if (!chunk && pluginPipelineIngressBytes > 0) {
|
||
|
|
acknowledgeDroppedTerminalDisplayBytes(ctx, pluginPipelineIngressBytes);
|
||
|
|
} else {
|
||
|
|
writeSessionData(ctx, term, chunk, pluginPipelineIngressBytes, meta);
|
||
|
|
}
|
||
|
|
ctx.onTerminalOutput?.(chunk, meta);
|
||
|
|
if (!ctx.hasConnectedRef.current) {
|
||
|
|
ctx.updateStatus("connected");
|
||
|
|
setTimeout(() => {
|
||
|
|
if (ctx.isVisibleRef?.current === false) {
|
||
|
|
notePendingOutputScrollIfEnabled(ctx);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!ctx.fitAddonRef.current) return;
|
||
|
|
try {
|
||
|
|
ctx.fitAddonRef.current.fit();
|
||
|
|
if (ctx.sessionRef.current) {
|
||
|
|
ctx.terminalBackend.resizeSession(ctx.sessionRef.current, term.cols, term.rows);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("Post-connect fit failed", err);
|
||
|
|
}
|
||
|
|
}, 100);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ replayBacklog: true },
|
||
|
|
);
|
||
|
|
|
||
|
|
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, async (evt) => {
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||
|
|
writeTerminalLine(ctx, term, exitMessage);
|
||
|
|
|
||
|
|
logger.info("[Terminal] Session exit, capturing data", {
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
hasCallback: !!ctx.onTerminalDataCapture,
|
||
|
|
hasSerializeAddon: !!ctx.serializeAddonRef.current,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
|
||
|
|
try {
|
||
|
|
await ctx.prepareKeywordHighlightSerialization?.();
|
||
|
|
const terminalData = ctx.serializeAddonRef.current.serialize();
|
||
|
|
logger.info("[Terminal] Serialized terminal data", {
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
dataLength: terminalData.length,
|
||
|
|
});
|
||
|
|
ctx.onTerminalDataCapture(ctx.sessionId, terminalData);
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("Failed to serialize terminal data:", err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||
|
|
});
|
||
|
|
|
||
|
|
ctx.onSessionAttached?.(id);
|
||
|
|
consumeRestoreCwdIntent(term, id);
|
||
|
|
scheduleStartupCommand(ctx, term, id);
|
||
|
|
} catch (err) {
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to start local shell: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Start Serial session
|
||
|
|
const startSerial = async (term: XTerm, options?: TerminalSessionStartOptions) => {
|
||
|
|
if (options?.signal?.aborted) return;
|
||
|
|
const { isCurrentAttempt, ignoreStaleAttemptUi, bootEpoch } = createAttemptGuards(options);
|
||
|
|
if (!ctx.serialConfig) {
|
||
|
|
ctx.setError("No serial configuration provided");
|
||
|
|
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
logger.info("[Serial] Starting serial session", {
|
||
|
|
port: ctx.serialConfig.path,
|
||
|
|
baudRate: ctx.serialConfig.baudRate,
|
||
|
|
});
|
||
|
|
|
||
|
|
const id = await ctx.terminalBackend.startSerialSession({
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
path: ctx.serialConfig.path,
|
||
|
|
baudRate: ctx.serialConfig.baudRate,
|
||
|
|
dataBits: ctx.serialConfig.dataBits,
|
||
|
|
stopBits: ctx.serialConfig.stopBits,
|
||
|
|
parity: ctx.serialConfig.parity,
|
||
|
|
flowControl: ctx.serialConfig.flowControl,
|
||
|
|
charset: ctx.host.charset,
|
||
|
|
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||
|
|
bootEpoch,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!tryAttachSessionToTerminal(ctx, term, id, {
|
||
|
|
isCurrentAttempt,
|
||
|
|
bootEpoch,
|
||
|
|
onExitMessage: (evt) =>
|
||
|
|
`\r\n[serial port closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||
|
|
// Convert lone LF to CRLF to prevent "staircase effect" in serial terminals
|
||
|
|
convertLfToCrlf: true,
|
||
|
|
})) {
|
||
|
|
// Only the current attempt may clear UI; a stale attach must not
|
||
|
|
// disconnect a newer reconnect that already re-armed boot.
|
||
|
|
if (isCurrentAttempt()) abortSessionStartAfterUnmount();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Serial connection is established once the session is attached to the terminal.
|
||
|
|
ctx.updateStatus("connected");
|
||
|
|
ctx.setProgressValue(100);
|
||
|
|
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
|
||
|
|
} catch (err) {
|
||
|
|
if (ignoreStaleAttemptUi()) return;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
ctx.setError(message);
|
||
|
|
writeTerminalLine(ctx, term, `\r\n[Failed to connect to serial port: ${message}]`);
|
||
|
|
ctx.updateStatus("disconnected");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const reattachSession = (term: XTerm) => {
|
||
|
|
const id = ctx.sessionRef.current;
|
||
|
|
if (!id) return false;
|
||
|
|
ctx.disposeDataRef.current?.();
|
||
|
|
ctx.disposeDataRef.current = null;
|
||
|
|
ctx.disposeExitRef.current?.();
|
||
|
|
ctx.disposeExitRef.current = null;
|
||
|
|
const isSerial = ctx.host.protocol === "serial" || ctx.host.id?.startsWith("serial-");
|
||
|
|
attachSessionToTerminal(ctx, term, id, {
|
||
|
|
convertLfToCrlf: isSerial,
|
||
|
|
sudoAutofillPassword: ctx.sudoAutofillPassword,
|
||
|
|
sudoAutofillCandidates: resolveSudoAutofillCandidates(),
|
||
|
|
});
|
||
|
|
attachTelnetEchoMode(id, { resetLocalEcho: false });
|
||
|
|
ctx.hasConnectedRef.current = true;
|
||
|
|
return true;
|
||
|
|
};
|
||
|
|
|
||
|
|
return { startSSH, startTelnet, startMosh, startEt, startPluginConnection, startLocal, startSerial, reattachSession };
|
||
|
|
};
|