Files
NetMesh/components/terminal/runtime/createTerminalSessionStarters.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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 };
};